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

View File

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

View File

@ -1,3 +1,6 @@
from lxml import html
# Django imports
from django.utils import timezone
@ -21,7 +24,8 @@ from plane.db.models import (
from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer
class IssueSerializer(BaseSerializer):
assignees = serializers.ListField(
@ -42,7 +46,6 @@ class IssueSerializer(BaseSerializer):
class Meta:
model = Issue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
@ -52,6 +55,10 @@ class IssueSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = [
"description",
"description_stripped",
]
def validate(self, data):
if (
@ -60,6 +67,15 @@ class IssueSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError("Start date cannot exceed target date")
try:
if(data.get("description_html", None) is not None):
parsed = html.fromstring(data["description_html"])
parsed_str = html.tostring(parsed, encoding='unicode')
data["description_html"] = parsed_str
except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
# Validate assignees are from project
if data.get("assignees", []):
@ -291,7 +307,6 @@ class IssueCommentSerializer(BaseSerializer):
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
@ -302,6 +317,21 @@ class IssueCommentSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = [
"comment_stripped",
"comment_json",
]
def validate(self, data):
try:
if(data.get("comment_html", None) is not None):
parsed = html.fromstring(data["comment_html"])
parsed_str = html.tostring(parsed, encoding='unicode')
data["comment_html"] = parsed_str
except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
return data
class IssueActivitySerializer(BaseSerializer):
@ -331,12 +361,23 @@ class ModuleIssueSerializer(BaseSerializer):
]
class IssueExpandSerializer(BaseSerializer):
# Serialize the related cycle. It's a OneToOne relation.
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
class LabelLiteSerializer(BaseSerializer):
# Serialize the related module. It's a OneToOne relation.
class Meta:
model = Label
fields = [
"id",
"name",
"color",
]
class IssueExpandSerializer(BaseSerializer):
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
labels = LabelLiteSerializer(read_only=True, many=True)
assignees = UserLiteSerializer(read_only=True, many=True)
state = StateLiteSerializer(read_only=True)
class Meta:
model = Issue
@ -349,4 +390,4 @@ class IssueExpandSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
]
]

View File

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

View File

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

View File

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

View File

@ -64,7 +64,7 @@ class StateAPIEndpoint(BaseAPIView):
)
if state.default:
return Response({"error": "Default state cannot be deleted"}, status=False)
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=state_id).exists()

View File

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

View File

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

View File

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

View File

@ -303,14 +303,6 @@ class OauthEndpoint(BaseAPIView):
instance_configuration = InstanceConfiguration.objects.values(
"key", "value"
)
# Check if instance is registered or not
instance = Instance.objects.first()
if instance is None and not instance.is_setup_done:
return Response(
{"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST,
)
if (
get_configuration_value(
instance_configuration,

View File

@ -77,7 +77,7 @@ class StateViewSet(BaseViewSet):
)
if state.default:
return Response({"error": "Default state cannot be deleted"}, status=False)
return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists()

View File

@ -1,7 +1,8 @@
# Python imports
import csv
import io
import os
import requests
import json
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
@ -17,8 +18,8 @@ from sentry_sdk import capture_exception
from plane.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_email_configuration
row_mapping = {
"state__name": "State",
@ -43,7 +44,7 @@ CYCLE_ID = "issue_cycle__cycle_id"
MODULE_ID = "issue_module__module_id"
def send_export_email(email, slug, csv_buffer):
def send_export_email(email, slug, csv_buffer, rows):
"""Helper function to send export email."""
subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {})
@ -55,47 +56,58 @@ def send_export_email(email, slug, csv_buffer):
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD:
# Check the instance registration
instance = Instance.objects.first()
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
"x-api-key": instance.api_key,
}
payload = {
"email": email,
"slug": slug,
"rows": rows,
}
_ = requests.post(
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/analytics/",
headers=headers,
json=payload,
)
return
connection = get_connection(
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
port=int(
get_configuration_value(
instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT")
)
),
username=get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER"),
),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
),
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=bool(EMAIL_USE_TLS),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
from_email=EMAIL_FROM,
to=[email],
connection=connection,
)
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False)
return
def get_assignee_details(slug, filters):
@ -463,8 +475,11 @@ def analytic_export_task(email, data, slug):
)
csv_buffer = generate_csv_from_rows(rows)
send_export_email(email, slug, csv_buffer)
send_export_email(email, slug, csv_buffer, rows)
return
except Exception as e:
print(e)
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@ -40,13 +40,10 @@ def forgot_password(first_name, email, uidb64, token, current_site):
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD:
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
# send the emails through control center
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
# headers
headers = {
"Content-Type": "application/json",
@ -61,7 +58,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
}
_ = requests.post(
f"{license_engine_base_url}/api/instances/users/forgot-password/",
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/forgot-password/",
headers=headers,
data=json.dumps(payload),
)

View File

@ -21,7 +21,6 @@ from plane.license.utils.instance_value import get_email_configuration
@shared_task
def magic_link(email, key, token, current_site):
try:
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
@ -36,13 +35,10 @@ def magic_link(email, key, token, current_site):
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD:
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
# send the emails through control center
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
@ -55,7 +51,7 @@ def magic_link(email, key, token, current_site):
}
_ = requests.post(
f"{license_engine_base_url}/api/instances/users/magic-code/",
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/magic-code/",
headers=headers,
data=json.dumps(payload),
)

View File

@ -32,13 +32,9 @@ def update_user_instance_user_count():
"x-api-key": instance.api_key,
}
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL")
if not license_engine_base_url:
raise Exception("License Engine base url is required")
# Update the license engine
_ = requests.post(
f"{license_engine_base_url}/api/instances/",
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers,
data=json.dumps(payload),
)

View File

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

View File

@ -51,15 +51,10 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured
if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD:
if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD):
# Check the instance registration
instance = Instance.objects.first()
# send the emails through control center
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
if not license_engine_base_url:
raise Exception("License engine base url is required")
headers = {
"Content-Type": "application/json",
"x-instance-id": instance.instance_id,
@ -73,7 +68,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
"email": email,
}
_ = requests.post(
f"{license_engine_base_url}/api/instances/users/workspace-invitation/",
f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/workspace-invitation/",
headers=headers,
data=json.dumps(payload),
)

View File

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

View File

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

View File

@ -290,7 +290,7 @@ CELERY_IMPORTS = (
# Sentry Settings
# Enable Sentry Settings
if bool(os.environ.get("SENTRY_DSN", False)):
if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get("SENTRY_DSN").startswith("https://"):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[
@ -324,4 +324,10 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
# Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
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),
subsequent=date_query[1],
term=term,
date_filter="created_at__date",
date_filter=date_term,
offset=date_query[2],
)
else:

View File

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

View File

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

View File

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

View File

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

View File

@ -84,7 +84,7 @@ export const EmailForm: React.FC<Props> = (props) => {
return (
<>
<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>
<p className="text-center text-sm text-onboarding-text-200 mt-3">
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 { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
@ -36,6 +36,8 @@ const authService = new AuthService();
export const PasswordForm: React.FC<Props> = (props) => {
const { email, updateEmail, handleStepChange, handleSignInRedirection } = props;
// states
const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// form info
@ -113,6 +115,8 @@ export const PasswordForm: React.FC<Props> = (props) => {
return;
}
setIsSendingResetPasswordLink(true);
authService
.sendResetPasswordLink({ email: emailFormValue })
.then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK))
@ -122,7 +126,8 @@ export const PasswordForm: React.FC<Props> = (props) => {
title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.",
})
);
)
.finally(() => setIsSendingResetPasswordLink(false));
};
return (
@ -189,9 +194,12 @@ export const PasswordForm: React.FC<Props> = (props) => {
<button
type="button"
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>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -52,7 +52,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const router = useRouter();
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;
@ -70,31 +73,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle>(CYCLE_DETAILS(cycleId as string), (prevData) => ({ ...(prevData as ICycle), ...data }), false);
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"
}
);
}
);
cycleDetailsStore.patchCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
};
const handleCopyText = () => {
@ -304,10 +283,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
cycleDetails.total_issues === 0
? "0 Issue"
: cycleDetails.total_issues === cycleDetails.completed_issues
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
return (
<>
@ -337,11 +316,12 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</button>
{!isCompleted && (
<CustomMenu width="lg" placement="bottom-end" ellipsis>
<CustomMenu.MenuItem onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true)
}
}>
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>

View File

@ -2,10 +2,12 @@ import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
// services
import { CycleService } from "services/cycle.service";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
//icons
import { ContrastIcon, TransferIcon } from "@plane/ui";
import { AlertCircle, Search, X } from "lucide-react";
@ -23,17 +25,19 @@ type Props = {
const cycleService = new CycleService();
export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) => {
export const TransferIssuesModal: React.FC<Props> = observer(({ isOpen, handleClose }) => {
const [query, setQuery] = useState("");
const { cycleIssues: cycleIssueStore } = useMobxStore();
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const { setToastAlert } = useToast();
const transferIssue = async (payload: any) => {
await cycleService
.transferIssues(workspaceSlug as string, projectId as string, cycleId as string, payload)
await cycleIssueStore
.transferIssuesFromCycle(workspaceSlug as string, projectId as string, cycleId as string, payload)
.then(() => {
setToastAlert({
type: "success",
@ -159,4 +163,4 @@ export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) =>
</Dialog>
</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";
// types
import type { TInboxStatus } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
export const InboxActionsHeader = observer(() => {
const [date, setDate] = useState(new Date());
@ -71,7 +72,7 @@ export const InboxActionsHeader = observer(() => {
}, [issue]);
const issueStatus = issue?.issue_inbox[0].status;
const isAllowed = userRole === 15 || userRole === 20;
const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
const today = new Date();
const tomorrow = new Date(today);

View File

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

View File

@ -26,14 +26,15 @@ export interface IssueDetailsProps {
workspaceSlug: string;
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
isAllowed: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
}
const fileService = new FileService();
export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
const { issue, handleFormSubmit, workspaceSlug, isAllowed } = props;
const { issue, handleFormSubmit, workspaceSlug, isAllowed, isSubmitting, setIsSubmitting } = props;
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [characterLimit, setCharacterLimit] = useState(false);
const { setShowAlert } = useReloadConfirmations();
@ -166,13 +167,6 @@ export const IssueDescriptionForm: FC<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>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import { IIssue } from "types";
// services
import { FileService } from "services/file.service";
import { useMobxStore } from "lib/mobx/store-provider";
import { EUserWorkspaceRoles } from "constants/workspace";
const fileService = new FileService();
@ -25,16 +26,27 @@ interface IPeekOverviewIssueDetails {
issueUpdate: (issue: Partial<IIssue>) => void;
issueReactionCreate: (reaction: string) => void;
issueReactionRemove: (reaction: string) => void;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
}
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
const { user: userStore } = useMobxStore();
const { currentProjectRole } = userStore;
const isAllowed = [15, 20].includes(currentProjectRole || 0);
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [characterLimit, setCharacterLimit] = useState(false);
// hooks
const { setShowAlert } = useReloadConfirmations();
@ -171,13 +183,6 @@ export const PeekOverviewIssueDetails: FC<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>
<IssueReaction
issueReactions={issueReactions}

View File

@ -12,6 +12,7 @@ import { IssueView } from "./view";
import { copyUrlToClipboard } from "helpers/string.helper";
// types
import { IIssue } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
interface IIssuePeekOverview {
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 (
<Fragment>

View File

@ -8,8 +8,7 @@ import { PeekOverviewIssueDetails } from "./issue-detail";
import { PeekOverviewProperties } from "./properties";
import { IssueComment } from "./activity";
import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui";
import { DeleteIssueModal } from "../delete-issue-modal";
import { DeleteArchivedIssueModal } from "../delete-archived-issue-modal";
import { DeleteIssueModal, DeleteArchivedIssueModal, IssueUpdateStatus } from "components/issues/";
// types
import { IIssue } from "types";
// hooks
@ -93,6 +92,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const updateRoutePeekId = () => {
if (issueId != peekIssueId) {
@ -216,33 +216,35 @@ export const IssueView: FC<IIssueView> = observer((props) => {
</div>
)}
</div>
<div className="flex items-center gap-4">
{issue?.created_by !== user?.id &&
!issue?.assignees.includes(user?.id ?? "") &&
!router.pathname.includes("[archivedIssueId]") && (
<Button
size="sm"
prependIcon={<Bell className="h-3 w-3" />}
variant="outline-primary"
className="hover:!bg-custom-primary-100/20"
onClick={() =>
issueSubscription && issueSubscription.subscribed
? issueSubscriptionRemove()
: issueSubscriptionCreate()
}
>
{issueSubscription && issueSubscription.subscribed ? "Unsubscribe" : "Subscribe"}
</Button>
)}
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200 -rotate-45" />
</button>
{!disableUserActions && (
<button onClick={() => setDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" />
<div className="flex items-center gap-x-4">
<IssueUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4">
{issue?.created_by !== user?.id &&
!issue?.assignees.includes(user?.id ?? "") &&
!router.pathname.includes("[archivedIssueId]") && (
<Button
size="sm"
prependIcon={<Bell className="h-3 w-3" />}
variant="outline-primary"
className="hover:!bg-custom-primary-100/20"
onClick={() =>
issueSubscription && issueSubscription.subscribed
? issueSubscriptionRemove()
: issueSubscriptionCreate()
}
>
{issueSubscription && issueSubscription.subscribed ? "Unsubscribe" : "Subscribe"}
</Button>
)}
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200 -rotate-45" />
</button>
)}
{!disableUserActions && (
<button onClick={() => setDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
)}
</div>
</div>
</div>
@ -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" />
)}
<PeekOverviewIssueDetails
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug}
issue={issue}
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={isArchived ? "pointer-events-none" : ""}>
<PeekOverviewIssueDetails
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug}
issue={issue}
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 { observer } from "mobx-react-lite";
import useSWR, { mutate } from "swr";
import { MinusCircle } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
@ -16,16 +17,17 @@ import {
IssueAttachments,
IssueDescriptionForm,
IssueReaction,
IssueUpdateStatus,
} from "components/issues";
import { useState } from "react";
import { SubIssuesRoot } from "./sub-issues";
// ui
import { CustomMenu, LayersIcon } from "@plane/ui";
// icons
import { MinusCircle } from "lucide-react";
import { CustomMenu, LayersIcon, StateGroupIcon } from "@plane/ui";
// types
import { IIssue, IIssueComment } from "types";
// fetch-keys
import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = {
issueDetails: IIssue;
@ -40,15 +42,25 @@ const issueCommentService = new IssueCommentService();
export const IssueMainContent: React.FC<Props> = observer((props) => {
const { issueDetails, submitChanges, uneditable = false } = props;
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { setToastAlert } = useToast();
const { user: userStore, project: projectStore } = useMobxStore();
const {
user: userStore,
project: projectStore,
projectState: { states },
} = useMobxStore();
const user = userStore.currentUser ?? undefined;
const userRole = userStore.currentProjectRole;
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : undefined;
const currentIssueState = projectId
? states[projectId.toString()]?.find((s) => s.id === issueDetails.state)
: undefined;
const { data: siblingIssues } = useSWR(
workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null,
@ -100,6 +112,8 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
);
};
const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
return (
<>
<div className="rounded-lg">
@ -162,11 +176,23 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
</CustomMenu>
</div>
) : 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
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug as string}
issue={issueDetails}
handleFormSubmit={submitChanges}
isAllowed={userRole === 20 || userRole === 15 || !uneditable}
isAllowed={isAllowed || !uneditable}
/>
<IssueReaction workspaceSlug={workspaceSlug} issueId={issueId} projectId={projectId} />

View File

@ -33,13 +33,14 @@ import {
import { CustomDatePicker } from "components/ui";
// icons
import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, User2 } from "lucide-react";
import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import type { IIssue, IIssueLink, linkDetails } from "types";
// fetch-keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = {
control: any;
@ -79,12 +80,15 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const [linkModal, setLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
const { user: userStore } = useMobxStore();
const {
user: userStore,
projectState: { states },
} = useMobxStore();
const user = userStore.currentUser;
const userRole = userStore.currentProjectRole;
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { workspaceSlug, projectId, issueId, inboxIssueId } = router.query;
const { isEstimateActive } = useEstimateOption();
@ -245,7 +249,11 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
setLinkModal(true);
};
const isNotAllowed = userRole === 5 || userRole === 10;
const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
const currentIssueState = projectId
? states[projectId.toString()]?.find((s) => s.id === issueDetail?.state)
: undefined;
return (
<>
@ -265,9 +273,20 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
)}
<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">
<h4 className="text-sm font-medium">
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
</h4>
<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}
</h4>
</div>
<div className="flex flex-wrap items-center gap-2">
{issueDetail?.created_by !== 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" />
</button>
)}
{!isNotAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
{isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
<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"
@ -325,7 +344,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarStateSelect
value={value}
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
value={value}
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
value={value}
onChange={(val) => submitChanges({ priority: val })}
disabled={isNotAllowed || uneditable}
disabled={!isAllowed || uneditable}
/>
)}
/>
@ -388,7 +407,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarEstimateSelect
value={value}
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);
}}
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));
}}
watch={watchIssue}
disabled={isNotAllowed || uneditable}
disabled={!isAllowed || uneditable}
/>
)}
{(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));
}}
watch={watchIssue}
disabled={isNotAllowed || uneditable}
disabled={!isAllowed || uneditable}
/>
)}
{(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));
}}
watch={watchIssue}
disabled={isNotAllowed || uneditable}
disabled={!isAllowed || uneditable}
/>
)}
{(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));
}}
watch={watchIssue}
disabled={isNotAllowed || uneditable}
disabled={!isAllowed || uneditable}
/>
)}
{(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"
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"
minDate={minDate ?? undefined}
disabled={isNotAllowed || uneditable}
disabled={!isAllowed || uneditable}
/>
)}
/>
@ -571,7 +590,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarCycleSelect
issueDetail={issueDetail}
handleCycleChange={handleCycleChange}
disabled={isNotAllowed || uneditable}
disabled={!isAllowed || uneditable}
/>
</div>
</div>
@ -586,7 +605,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarModuleSelect
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
disabled={isNotAllowed || uneditable}
disabled={!isAllowed || uneditable}
/>
</div>
</div>
@ -605,7 +624,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
issueDetails={issueDetail}
labelList={issueDetail?.labels ?? []}
submitChanges={submitChanges}
isNotAllowed={isNotAllowed}
isNotAllowed={!isAllowed}
uneditable={uneditable ?? false}
/>
</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="flex items-center justify-between gap-2">
<h4>Links</h4>
{!isNotAllowed && (
{isAllowed && (
<button
type="button"
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";
// fetch keys
import { SUB_ISSUES } from "constants/fetch-keys";
import { EUserWorkspaceRoles } from "constants/workspace";
export interface ISubIssuesRoot {
parentIssue: IIssue;
@ -176,7 +177,7 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
[updateIssueStructure, projectId, updateIssue, user, workspaceSlug]
);
const isEditable = userRole === 5 || userRole === 10 ? false : true;
const isEditable = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
const mutateSubIssues = (parentIssueId: string | null) => {
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";
// constant
import { MODULE_STATUS } from "constants/module";
import { EUserWorkspaceRoles } from "constants/workspace";
const defaultValues: Partial<IModule> = {
lead: "",
@ -74,20 +75,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const submitChanges = (data: Partial<IModule>) => {
if (!workspaceSlug || !projectId || !moduleId) return;
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));
moduleStore.updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId, data);
};
const handleCreateLink = async (formData: ModuleLink) => {
@ -588,10 +576,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
handleEditLink={handleEditLink}
handleDeleteLink={handleDeleteLink}
userAuth={{
isGuest: userRole === 5,
isViewer: userRole === 10,
isMember: userRole === 15,
isOwner: userRole === 20,
isGuest: userRole === EUserWorkspaceRoles.GUEST,
isViewer: userRole === EUserWorkspaceRoles.VIEWER,
isMember: userRole === EUserWorkspaceRoles.MEMBER,
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 { observer } from "mobx-react-lite";
import Image from "next/image";
import { useTheme } from "next-themes";
import { useRouter } from "next/router";
import { Lightbulb } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components
import { SignInRoot } from "components/account";
// ui
@ -14,122 +15,84 @@ import { Loader, Spinner } from "@plane/ui";
// images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
import latestFeatures from "public/onboarding/onboarding-pages.svg";
// types
import { IUser, IUserSettings } from "types";
export type AuthType = "sign-in" | "sign-up";
export const SignInView = observer(() => {
// store
const {
user: { fetchCurrentUser, fetchCurrentUserSettings },
user: { currentUser },
appConfig: { envConfig },
} = useMobxStore();
// router
const router = useRouter();
const { next: next_url } = router.query;
// states
const [isLoading, setLoading] = useState(false);
// next-themes
const { resolvedTheme } = useTheme();
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(() => setLoading(false));
},
[fetchCurrentUserSettings, router, next_url]
);
const mutateUserInfo = useCallback(async () => {
await fetchCurrentUser().then(async (user) => {
await handleSignInRedirection(user);
});
}, [fetchCurrentUser, handleSignInRedirection]);
// sign in redirection hook
const { isRedirecting, handleRedirection } = useSignInRedirection();
useEffect(() => {
mutateUserInfo();
}, [mutateUserInfo]);
handleRedirection();
}, [handleRedirection]);
if (isRedirecting || currentUser)
return (
<div className="grid place-items-center h-screen">
<Spinner />
</div>
);
return (
<>
{isLoading ? (
<div className="grid place-items-center h-screen">
<Spinner />
<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 gap-x-2 py-10 items-center">
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
<span className="font-semibold text-2xl sm:text-3xl">Plane</span>
</div>
) : (
<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 gap-x-2 py-10 items-center">
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
<span className="font-semibold text-2xl sm:text-3xl">Plane</span>
</div>
<div className="h-full bg-onboarding-gradient-100 md:w-2/3 sm:w-4/5 px-4 pt-4 rounded-t-md mx-auto shadow-sm border-x border-t border-custom-border-200 ">
<div className="px-7 sm:px-0 bg-onboarding-gradient-200 h-full pt-24 pb-56 rounded-t-md overflow-auto">
{!envConfig ? (
<div className="pt-10 mx-auto flex justify-center">
<div>
<Loader className="space-y-4 w-full pb-4 mx-auto">
<Loader.Item height="46px" width="360px" />
<Loader.Item height="46px" width="360px" />
</Loader>
<Loader className="space-y-4 w-full pt-4 mx-auto">
<Loader.Item height="46px" width="360px" />
<Loader.Item height="46px" width="360px" />
</Loader>
</div>
</div>
</div>
) : (
<>
<SignInRoot />
<div className="h-full bg-onboarding-gradient-100 md:w-2/3 sm:w-4/5 px-4 pt-4 rounded-t-md mx-auto shadow-sm border-x border-t border-custom-border-200 ">
<div className="px-7 sm:px-0 bg-onboarding-gradient-200 h-full pt-24 pb-56 rounded-t-md overflow-auto">
{!envConfig ? (
<div className="pt-10 mx-auto flex justify-center">
<div>
<Loader className="space-y-4 w-full pb-4 mx-auto">
<Loader.Item height="46px" width="360px" />
<Loader.Item height="46px" width="360px" />
</Loader>
<Loader className="space-y-4 w-full pt-4 mx-auto">
<Loader.Item height="46px" width="360px" />
<Loader.Item height="46px" width="360px" />
</Loader>
</div>
<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" />
<p className="text-sm text-left text-onboarding-text-100">
Pages gets a facelift! Write anything and use Galileo to help you start.{" "}
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
<span className="font-medium text-sm underline hover:cursor-pointer">Learn more</span>
</Link>
</p>
</div>
<div className="border border-onboarding-border-200 sm:w-96 sm:h-52 object-cover mt-8 mx-auto rounded-md bg-onboarding-background-100 overflow-hidden">
<div className="h-[90%]">
<Image
src={latestFeatures}
alt="Plane Issues"
className={`rounded-md h-full ml-8 -mt-2 ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
} `}
/>
</div>
) : (
<>
<SignInRoot handleSignInRedirection={mutateUserInfo} />
<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" />
<p className="text-sm text-left text-onboarding-text-100">
Pages gets a facelift! Write anything and use Galileo to help you start.{" "}
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
<span className="font-medium text-sm underline hover:cursor-pointer">Learn more</span>
</Link>
</p>
</div>
<div className="border border-onboarding-border-200 sm:w-96 sm:h-52 object-cover mt-8 mx-auto rounded-md bg-onboarding-background-100 overflow-hidden">
<div className="h-[90%]">
<Image
src={latestFeatures}
alt="Plane Issues"
className={`rounded-md h-full ml-8 -mt-2 ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
} `}
/>
</div>
</div>
</>
)}
</div>
</div>
</div>
</>
)}
</div>
)}
</>
</div>
</div>
);
});

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ import useToast from "hooks/use-toast";
// types
import { IProjectMember, TUserProjectRole } from "types";
// constants
import { ROLE } from "constants/workspace";
import { EUserWorkspaceRoles, ROLE } from "constants/workspace";
type Props = {
isOpen: boolean;
@ -246,7 +246,8 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
width="w-full"
>
{Object.entries(ROLE).map(([key, label]) => {
if (parseInt(key) > (currentProjectRole ?? 5)) return null;
if (parseInt(key) > (currentProjectRole ?? EUserWorkspaceRoles.GUEST))
return null;
return (
<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";
// types
import { IProject } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = {};
@ -56,7 +57,7 @@ export const ProjectFeaturesList: FC<Props> = observer(() => {
user: { currentUser, currentProjectRole },
trackEvent: { setTrackElement, postHogEventTracker },
} = useMobxStore();
const isAdmin = currentProjectRole === 20;
const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN;
// hooks
const { setToastAlert } = useToast();
@ -97,7 +98,7 @@ export const ProjectFeaturesList: FC<Props> = observer(() => {
project_id: currentProjectDetails?.id,
project_name: currentProjectDetails?.name,
project_identifier: currentProjectDetails?.identifier,
enabled: !currentProjectDetails?.[feature.property as keyof IProject]
enabled: !currentProjectDetails?.[feature.property as keyof IProject],
});
handleSubmit({
[feature.property]: !currentProjectDetails?.[feature.property as keyof IProject],

View File

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

View File

@ -243,7 +243,7 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
: "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>
</Tooltip>
</div>

View File

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

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";
// components
import { ProjectSettingsSidebar } from "./sidebar";
import { useMobxStore } from "lib/mobx/store-provider";
import { EUserWorkspaceRoles } from "constants/workspace";
import { NotAuthorizedView } from "components/auth-screens";
import { observer } from "mobx-react-lite";
export interface IProjectSettingLayout {
children: ReactNode;
}
export const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
export const ProjectSettingLayout: FC<IProjectSettingLayout> = observer((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="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<ProjectSettingsSidebar />
@ -17,4 +29,4 @@ export const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
{children}
</div>
);
};
});

View File

@ -14,6 +14,7 @@ import { ProjectSettingHeader } from "components/headers";
// types
import { NextPageWithLayout } from "types/app";
import { IProject } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
const AutomationSettingsPage: NextPageWithLayout = observer(() => {
const router = useRouter();
@ -39,7 +40,7 @@ const AutomationSettingsPage: NextPageWithLayout = observer(() => {
});
};
const isAdmin = currentProjectRole === 20;
const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN;
return (
<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";
// types
import { NextPageWithLayout } from "types/app";
import { useMobxStore } from "lib/mobx/store-provider";
import { EUserWorkspaceRoles } from "constants/workspace";
import { observer } from "mobx-react-lite";
const EstimatesSettingsPage: NextPageWithLayout = () => (
<div className="pr-9 py-8 w-full overflow-y-auto">
<EstimatesList />
</div>
);
const EstimatesSettingsPage: NextPageWithLayout = observer(() => {
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 />
</div>
);
});
EstimatesSettingsPage.getLayout = function getLayout(page: ReactElement) {
return (

View File

@ -1,4 +1,4 @@
import { ReactElement, useCallback } from "react";
import { ReactElement } from "react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
@ -9,6 +9,7 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// layouts
import DefaultLayout from "layouts/default-layout";
// ui
@ -20,9 +21,6 @@ import latestFeatures from "public/onboarding/onboarding-pages.svg";
import { checkEmailValidity } from "helpers/string.helper";
// type
import { NextPageWithLayout } from "types/app";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { IUser, IUserSettings } from "types";
type TResetPasswordFormValues = {
email: string;
@ -45,10 +43,8 @@ const HomePage: NextPageWithLayout = () => {
const { resolvedTheme } = useTheme();
// toast
const { setToastAlert } = useToast();
// mobx store
const {
user: { fetchCurrentUser, fetchCurrentUserSettings },
} = useMobxStore();
// sign in redirection hook
const { handleRedirection } = useSignInRedirection();
// form info
const {
control,
@ -61,31 +57,6 @@ const HomePage: NextPageWithLayout = () => {
},
});
const handleSignInRedirection = useCallback(
async (user: IUser) => {
// if the user is not onboarded, redirect them to the onboarding page
if (!user.is_onboarded) {
router.push("/onboarding");
return;
}
// if the user is onboarded, fetch their last workspace details
await fetchCurrentUserSettings().then((userSettings: IUserSettings) => {
const workspaceSlug =
userSettings?.workspace?.last_workspace_slug || userSettings?.workspace?.fallback_workspace_slug;
if (workspaceSlug) router.push(`/${workspaceSlug}`);
else router.push("/profile");
});
},
[fetchCurrentUserSettings, router]
);
const mutateUserInfo = useCallback(async () => {
await fetchCurrentUser().then(async (user) => {
await handleSignInRedirection(user);
});
}, [fetchCurrentUser, handleSignInRedirection]);
const handleResetPassword = async (formData: TResetPasswordFormValues) => {
if (!uidb64 || !token || !email) return;
@ -95,7 +66,7 @@ const HomePage: NextPageWithLayout = () => {
await authService
.resetPassword(uidb64.toString(), token.toString(), payload)
.then(() => mutateUserInfo())
.then(() => handleRedirection())
.catch((err) =>
setToastAlert({
type: "error",

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

View File

@ -62,7 +62,14 @@ export interface ICycleIssuesStore {
issueId: string,
issueBridgeId: string
) => Promise<IIssue>;
transferIssuesFromCycle: (
workspaceSlug: string,
projectId: string,
cycleId: string,
payload: {
new_cycle_id: string;
}
) => Promise<IIssue>;
viewFlags: ViewFlags;
}
@ -103,6 +110,7 @@ export class CycleIssuesStore extends IssueBaseStore implements ICycleIssuesStor
quickAddIssue: action,
addIssueToCycle: action,
removeIssueFromCycle: action,
transferIssuesFromCycle: action,
});
this.rootStore = _rootStore;
@ -348,4 +356,28 @@ export class CycleIssuesStore extends IssueBaseStore implements ICycleIssuesStor
throw error;
}
};
transferIssuesFromCycle = async (
workspaceSlug: string,
projectId: string,
cycleId: string,
payload: {
new_cycle_id: string;
}
) => {
try {
const response = await this.cycleService.transferIssues(
workspaceSlug as string,
projectId as string,
cycleId as string,
payload
);
await this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId);
return response;
} catch (error) {
this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId);
throw error;
}
};
}

View File

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