Compare commits

..

1 Commits

Author SHA1 Message Date
NarayanBavisetti 827a00c1d3 chore: instance admin passowrd change 2023-11-23 13:07:42 +05:30
426 changed files with 6971 additions and 15260 deletions
-6
View File
@@ -1,11 +1,5 @@
version = 1
exclude_patterns = [
"bin/**",
"**/node_modules/",
"**/*.min.js"
]
[[analyzers]]
name = "shell"
+18 -16
View File
@@ -1,10 +1,11 @@
name: Branch Build
on:
pull_request:
types:
types:
- closed
branches:
branches:
- master
- release
- qa
@@ -22,7 +23,7 @@ jobs:
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
# - name: Set Target Branch Name on PR close
# if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }}
# run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
@@ -30,7 +31,7 @@ jobs:
# - name: Set Target Branch Name on other than PR close
# if: ${{ github.event_name == 'push' }}
# run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- uses: ASzc/change-string-case-action@v2
id: gh_branch_upper_lower
with:
@@ -40,22 +41,22 @@ jobs:
id: gh_branch_replace_slash
with:
source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }}
find: "/"
replace: "-"
find: '/'
replace: '-'
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_dot
with:
source: ${{ steps.gh_branch_replace_slash.outputs.value }}
find: "."
replace: ""
find: '.'
replace: ''
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_clean
with:
source: ${{ steps.gh_branch_replace_dot.outputs.value }}
find: "_"
replace: ""
find: '_'
replace: ''
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
@@ -76,6 +77,7 @@ jobs:
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
@@ -87,11 +89,11 @@ jobs:
!./deploy
!./web
outputs:
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [branch_build_and_push]
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -121,7 +123,7 @@ jobs:
branch_build_push_space:
runs-on: ubuntu-20.04
needs: [branch_build_and_push]
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -151,7 +153,7 @@ jobs:
branch_build_push_backend:
runs-on: ubuntu-20.04
needs: [branch_build_and_push]
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
@@ -181,7 +183,7 @@ jobs:
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [branch_build_and_push]
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
+2 -3
View File
@@ -9,9 +9,8 @@ from .issue import (
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
IssueExpandSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer
from .module import ModuleSerializer, ModuleIssueSerializer
from .inbox import InboxIssueSerializer
+1 -8
View File
@@ -46,11 +46,4 @@ class CycleIssueSerializer(BaseSerializer):
"workspace",
"project",
"cycle",
]
class CycleLiteSerializer(BaseSerializer):
class Meta:
model = Cycle
fields = "__all__"
]
+1 -7
View File
@@ -8,12 +8,6 @@ class InboxIssueSerializer(BaseSerializer):
model = InboxIssue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
]
+22 -55
View File
@@ -19,8 +19,6 @@ from plane.db.models import (
ProjectMember,
)
from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer
class IssueSerializer(BaseSerializer):
@@ -44,7 +42,6 @@ class IssueSerializer(BaseSerializer):
model = Issue
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
@@ -63,9 +60,9 @@ class IssueSerializer(BaseSerializer):
# Validate assignees are from project
if data.get("assignees", []):
print(data.get("assignees"))
data["assignees"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
is_active=True,
member_id__in=data["assignees"],
).values_list("member_id", flat=True)
@@ -91,7 +88,7 @@ class IssueSerializer(BaseSerializer):
if (
data.get("parent")
and not Issue.objects.filter(
workspace_id=self.context.get("workspace_id"), pk=data.get("parent")
workspce_id=self.context.get("workspace_id"), pk=data.get("parent")
).exists()
):
raise serializers.ValidationError(
@@ -234,13 +231,8 @@ class LabelSerializer(BaseSerializer):
model = Label
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
@@ -249,14 +241,13 @@ class IssueLinkSerializer(BaseSerializer):
model = IssueLink
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"issue",
]
# Validation if url already exists
@@ -275,14 +266,13 @@ class IssueAttachmentSerializer(BaseSerializer):
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace",
"project",
"issue",
]
@@ -293,7 +283,6 @@ class IssueCommentSerializer(BaseSerializer):
model = IssueComment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"issue",
@@ -304,49 +293,27 @@ class IssueCommentSerializer(BaseSerializer):
]
class IssueActivitySerializer(BaseSerializer):
class IssueAttachmentSerializer(BaseSerializer):
class Meta:
model = IssueActivity
exclude = [
"created_by",
"updated_by",
]
class CycleIssueSerializer(BaseSerializer):
cycle = CycleSerializer(read_only=True)
class Meta:
fields = [
"cycle",
]
class ModuleIssueSerializer(BaseSerializer):
module = ModuleSerializer(read_only=True)
class Meta:
fields = [
"module",
]
class IssueExpandSerializer(BaseSerializer):
# Serialize the related cycle. It's a OneToOne relation.
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
# Serialize the related module. It's a OneToOne relation.
module = ModuleLiteSerializer(source="issue_module.module", read_only=True)
class Meta:
model = Issue
model = IssueAttachment
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
"workspace",
"project",
"issue",
]
class IssueActivitySerializer(BaseSerializer):
class Meta:
model = IssueActivity
fields = "__all__"
exclude = [
"created_by",
"udpated_by",
]
+2 -9
View File
@@ -21,6 +21,7 @@ class ModuleSerializer(BaseSerializer):
write_only=True,
required=False,
)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
completed_issues = serializers.IntegerField(read_only=True)
@@ -32,7 +33,6 @@ class ModuleSerializer(BaseSerializer):
model = Module
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
@@ -152,11 +152,4 @@ class ModuleLinkSerializer(BaseSerializer):
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
return ModuleLink.objects.create(**validated_data)
class ModuleLiteSerializer(BaseSerializer):
class Meta:
model = Module
fields = "__all__"
return ModuleLink.objects.create(**validated_data)
+1 -5
View File
@@ -20,12 +20,8 @@ class ProjectSerializer(BaseSerializer):
model = Project
fields = "__all__"
read_only_fields = [
"id",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
"id",
]
def validate(self, data):
+2 -2
View File
@@ -13,7 +13,7 @@ urlpatterns = [
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/",
CycleAPIEndpoint.as_view(),
name="cycles",
),
@@ -23,7 +23,7 @@ urlpatterns = [
name="cycle-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
CycleIssueAPIEndpoint.as_view(),
name="cycle-issues",
),
+2 -2
View File
@@ -5,12 +5,12 @@ from plane.api.views import InboxIssueAPIEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssueAPIEndpoint.as_view(),
name="inbox-issue",
),
+7 -7
View File
@@ -15,27 +15,27 @@ urlpatterns = [
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/",
IssueAPIEndpoint.as_view(),
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/<uuid:pk>/",
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/",
IssueLinkAPIEndpoint.as_view(),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/<uuid:pk>/",
IssueLinkAPIEndpoint.as_view(),
name="link",
),
@@ -50,12 +50,12 @@ urlpatterns = [
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/",
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/<uuid:pk>/",
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
+1 -1
View File
@@ -19,7 +19,7 @@ urlpatterns = [
name="module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/",
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),
+1 -1
View File
@@ -9,7 +9,7 @@ urlpatterns = [
name="project",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/",
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectAPIEndpoint.as_view(),
name="project",
),
-5
View File
@@ -8,9 +8,4 @@ urlpatterns = [
StateAPIEndpoint.as_view(),
name="states",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/",
StateAPIEndpoint.as_view(),
name="states",
),
]
+8 -12
View File
@@ -7,6 +7,7 @@ from django.conf import settings
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.views import APIView
@@ -35,33 +36,28 @@ class TimezoneMixin:
else:
timezone.deactivate()
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
payload=response.data,
kw=self.kwargs,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
)
return response
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [
APIKeyAuthentication,
@@ -143,13 +139,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
response = super().finalize_response(request, response, *args, **kwargs)
# Add custom headers if they exist in the request META
ratelimit_remaining = request.META.get("X-RateLimit-Remaining")
ratelimit_remaining = request.META.get('X-RateLimit-Remaining')
if ratelimit_remaining is not None:
response["X-RateLimit-Remaining"] = ratelimit_remaining
response['X-RateLimit-Remaining'] = ratelimit_remaining
ratelimit_reset = request.META.get("X-RateLimit-Reset")
ratelimit_reset = request.META.get('X-RateLimit-Reset')
if ratelimit_reset is not None:
response["X-RateLimit-Reset"] = ratelimit_reset
response['X-RateLimit-Reset'] = ratelimit_reset
return response
@@ -173,4 +169,4 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
expand = [
expand for expand in self.request.GET.get("expand", "").split(",") if expand
]
return expand if expand else None
return expand if expand else None
+10 -9
View File
@@ -17,6 +17,7 @@ from plane.app.permissions import ProjectEntityPermission
from plane.api.serializers import (
CycleSerializer,
CycleIssueSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -141,6 +142,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
)
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle
if cycle_view == "current":
@@ -291,7 +293,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=None,
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
@@ -303,15 +305,14 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle issues.
"""
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
webhook_event = "cycle"
permission_classes = [
ProjectEntityPermission,
]
@@ -456,7 +457,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": str(issues)}),
requested_data=json.dumps({"cycles_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
@@ -477,9 +478,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id, issue_id):
def delete(self, request, slug, project_id, cycle_id, pk):
cycle_issue = CycleIssue.objects.get(
issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
@@ -492,7 +493,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(self.request.user.id),
issue_id=str(issue_id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
+20 -97
View File
@@ -14,7 +14,7 @@ from rest_framework.response import Response
from .base import BaseAPIView
from plane.app.permissions import ProjectLitePermission
from plane.api.serializers import InboxIssueSerializer, IssueSerializer
from plane.db.models import InboxIssue, Issue, State, ProjectMember, Project, Inbox
from plane.db.models import InboxIssue, Issue, State, ProjectMember
from plane.bgtasks.issue_activites_task import issue_activity
@@ -37,39 +37,29 @@ class InboxIssueAPIEndpoint(BaseAPIView):
]
def get_queryset(self):
inbox = Inbox.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
).first()
project = Project.objects.get(
workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")
)
if inbox is None and not project.inbox_view:
return InboxIssue.objects.none()
return (
InboxIssue.objects.filter(
return self.filter_queryset(
super()
.get_queryset()
.filter(
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
inbox_id=inbox.id,
inbox_id=self.kwargs.get("inbox_id"),
)
.select_related("issue", "workspace", "project")
.order_by(self.kwargs.get("order_by", "-created_at"))
)
def get(self, request, slug, project_id, pk=None):
def get(self, request, slug, project_id, inbox_id, pk=None):
if pk:
inbox_issue_queryset = self.get_queryset().get(pk=pk)
inbox_issue_data = InboxIssueSerializer(
inbox_issue_queryset,
issue_queryset = self.get_queryset().get(pk=pk)
issues_data = InboxIssueSerializer(
issue_queryset,
fields=self.fields,
expand=self.expand,
).data
return Response(
inbox_issue_data,
issues_data,
status=status.HTTP_200_OK,
)
issue_queryset = self.get_queryset()
@@ -84,30 +74,12 @@ class InboxIssueAPIEndpoint(BaseAPIView):
).data,
)
def post(self, request, slug, project_id):
def post(self, request, slug, project_id, inbox_id):
if not request.data.get("issue", {}).get("name", False):
return Response(
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
)
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Check for valid priority
if not request.data.get("issue", {}).get("priority", "none") in [
"low",
@@ -151,45 +123,21 @@ class InboxIssueAPIEndpoint(BaseAPIView):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# create an inbox issue
inbox_issue = InboxIssue.objects.create(
inbox_id=inbox.id,
InboxIssue.objects.create(
inbox_id=inbox_id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "in-app"),
)
serializer = InboxIssueSerializer(inbox_issue)
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the inbox issue
def patch(self, request, slug, project_id, inbox_id, pk):
inbox_issue = InboxIssue.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
@@ -197,7 +145,6 @@ class InboxIssueAPIEndpoint(BaseAPIView):
member=request.user,
is_active=True,
)
# Only project members admins and created_by users can access this endpoint
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id
@@ -297,33 +244,10 @@ class InboxIssueAPIEndpoint(BaseAPIView):
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
)
def delete(self, request, slug, project_id, pk):
inbox = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project settings"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the inbox issue
def delete(self, request, slug, project_id, inbox_id, pk):
inbox_issue = InboxIssue.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
inbox_id=inbox.id,
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
)
# Get the project member
project_member = ProjectMember.objects.get(
workspace__slug=slug,
@@ -332,7 +256,6 @@ class InboxIssueAPIEndpoint(BaseAPIView):
is_active=True,
)
# Check the inbox issue created
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
request.user.id
):
@@ -349,4 +272,4 @@ class InboxIssueAPIEndpoint(BaseAPIView):
).delete()
inbox_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(status=status.HTTP_204_NO_CONTENT)
+40 -41
View File
@@ -22,6 +22,7 @@ from django.utils import timezone
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from .base import BaseAPIView, WebhookMixin
@@ -40,12 +41,14 @@ from plane.db.models import (
IssueComment,
IssueActivity,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
from plane.api.serializers import (
IssueSerializer,
LabelSerializer,
IssueLinkSerializer,
IssueCommentSerializer,
IssueAttachmentSerializer,
IssueActivitySerializer,
)
@@ -100,6 +103,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
@@ -108,6 +112,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
@@ -273,7 +278,7 @@ class LabelAPIEndpoint(BaseAPIView):
def get_queryset(self):
return (
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
@@ -297,29 +302,29 @@ class LabelAPIEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
if pk is None:
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels,
many=True,
fields=self.fields,
expand=self.expand,
).data,
if pk:
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(
label,
fields=self.fields,
expand=self.expand,
)
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
def patch(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
@@ -351,31 +356,25 @@ class IssueLinkAPIEndpoint(BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id, issue_id, pk=None):
if pk is None:
issue_links = self.get_queryset()
def get(self, request, slug, project_id, pk=None):
if pk:
label = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(
issue_links,
label,
fields=self.fields,
expand=self.expand,
)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
issue_link = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(
issue_link,
fields=self.fields,
expand=self.expand,
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug, project_id, issue_id):
serializer = IssueLinkSerializer(data=request.data)
@@ -450,7 +449,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
webhook_event = "issue-comment"
permission_classes = [
ProjectLitePermission,
]
@@ -588,7 +587,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
serializer = IssueActivitySerializer(issue_activities)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer(
+6 -15
View File
@@ -129,14 +129,6 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, pk):
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
serializer = ModuleSerializer(module, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, pk=None):
if pk:
@@ -176,7 +168,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=None,
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
@@ -194,8 +186,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
webhook_event = "module"
permission_classes = [
ProjectEntityPermission,
@@ -332,7 +323,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Capture Issue Activity
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"modules_list": str(issues)}),
requested_data=json.dumps({"modules_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
@@ -352,9 +343,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id, issue_id):
def delete(self, request, slug, project_id, module_id, pk):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
)
module_issue.delete()
issue_activity.delay(
@@ -366,7 +357,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=str(issue_id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
+12 -15
View File
@@ -94,8 +94,8 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id=None):
if project_id is None:
def get(self, request, slug, pk=None):
if pk is None:
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
@@ -114,7 +114,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
).select_related("member"),
)
)
.order_by(request.GET.get("order_by", "sort_order"))
.order_by("sort_order", "name")
)
return self.paginate(
request=request,
@@ -123,13 +123,15 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
projects, many=True, fields=self.fields, expand=self.expand,
).data,
)
project = self.get_queryset().get(workspace__slug=slug, pk=project_id)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
project = self.get_queryset().get(workspace__slug=slug, pk=pk)
serializer = ProjectSerializer(project, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug):
try:
workspace = Workspace.objects.get(slug=slug)
serializer = ProjectSerializer(
data={**request.data}, context={"workspace_id": workspace.id}
)
@@ -234,10 +236,10 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_410_GONE,
)
def patch(self, request, slug, project_id=None):
def patch(self, request, slug, pk=None):
try:
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=project_id)
project = Project.objects.get(pk=pk)
serializer = ProjectSerializer(
project,
@@ -258,7 +260,7 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
name="Triage",
group="backlog",
description="Default state for managing all Inbox Issues",
project_id=project_id,
project_id=pk,
color="#ff7700",
)
@@ -280,9 +282,4 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
)
def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
)
+12 -10
View File
@@ -23,8 +23,10 @@ class StateAPIEndpoint(BaseAPIView):
]
def get_queryset(self):
return (
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(~Q(name="Triage"))
@@ -40,9 +42,9 @@ class StateAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, state_id=None):
if state_id:
serializer = StateSerializer(self.get_queryset().get(pk=state_id))
def get(self, request, slug, project_id, pk=None):
if pk:
serializer = StateSerializer(self.get_queryset().get(pk=pk))
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
@@ -55,10 +57,10 @@ class StateAPIEndpoint(BaseAPIView):
).data,
)
def delete(self, request, slug, project_id, state_id):
def delete(self, request, slug, project_id, pk):
state = State.objects.get(
~Q(name="Triage"),
pk=state_id,
pk=pk,
project_id=project_id,
workspace__slug=slug,
)
@@ -67,7 +69,7 @@ class StateAPIEndpoint(BaseAPIView):
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=state_id).exists()
issue_exist = Issue.issue_objects.filter(state=pk).exists()
if issue_exist:
return Response(
@@ -78,8 +80,8 @@ class StateAPIEndpoint(BaseAPIView):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, project_id, state_id=None):
state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id)
def patch(self, request, slug, project_id, pk=None):
state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk)
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
-1
View File
@@ -225,7 +225,6 @@ class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = IssueActivity
-10
View File
@@ -155,16 +155,6 @@ class ChangePasswordSerializer(serializers.Serializer):
"""
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True)
confirm_password = serializers.CharField(required=True)
def validate(self, data):
if data.get("old_password") == data.get("new_password"):
raise serializers.ValidationError({"error": "New password cannot be same as old password."})
if data.get("new_password") != data.get("confirm_password"):
raise serializers.ValidationError({"error": "Confirm password should be same as the new password."})
return data
class ResetPasswordSerializer(serializers.Serializer):
@@ -1,9 +1,3 @@
# Python imports
import urllib
import socket
import ipaddress
from urllib.parse import urlparse
# Third party imports
from rest_framework import serializers
@@ -15,42 +9,6 @@ from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
def validate(self, data):
url = data.get("url", None)
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get('request')
disallowed_domains = ['plane.so',] # Add your disallowed domains here
if request:
request_host = request.get_host().split(':')[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return data
class Meta:
model = Webhook
fields = "__all__"
@@ -21,22 +21,6 @@ class WorkSpaceSerializer(BaseSerializer):
total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
def validated(self, data):
if data.get("slug") in [
"404",
"accounts",
"api",
"create-workspace",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
]:
raise serializers.ValidationError({"slug": "Slug is not valid"})
class Meta:
model = Workspace
fields = "__all__"
-10
View File
@@ -4,7 +4,6 @@ from django.urls import path
from plane.app.views import (
FileAssetEndpoint,
UserAssetsEndpoint,
FileAssetViewSet,
)
@@ -29,13 +28,4 @@ urlpatterns = [
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/restore/",
FileAssetViewSet.as_view(
{
"post": "restore",
}
),
name="file-assets-restore",
),
]
+2 -2
View File
@@ -65,7 +65,7 @@ urlpatterns = [
name="project-member-invite",
),
path(
"users/me/workspaces/<str:slug>/projects/invitations/",
"users/me/invitations/projects/",
UserProjectInvitationsViewset.as_view(
{
"get": "list",
@@ -75,7 +75,7 @@ urlpatterns = [
name="user-project-invitations",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/join/<uuid:pk>/",
"workspaces/<str:slug>/projects/join/",
ProjectJoinEndpoint.as_view(),
name="project-join",
),
+1 -1
View File
@@ -63,7 +63,7 @@ urlpatterns = [
name="user-tour",
),
path(
"users/me/activities/",
"users/workspaces/<str:slug>/activities/",
UserActivityEndpoint.as_view(),
name="user-activities",
),
-6
View File
@@ -18,7 +18,6 @@ from plane.app.views import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceUserProfileIssuesGroupedEndpoint
)
@@ -190,11 +189,6 @@ urlpatterns = [
WorkspaceUserProfileIssuesEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path(
"v3/workspaces/<str:slug>/user-issues/<uuid:user_id>/",
WorkspaceUserProfileIssuesGroupedEndpoint.as_view(),
name="workspace-user-profile-issues",
),
path(
"workspaces/<str:slug>/labels/",
WorkspaceLabelsEndpoint.as_view(),
+1 -2
View File
@@ -44,7 +44,6 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceUserProfileIssuesGroupedEndpoint
)
from .state import StateViewSet
from .view import (
@@ -61,7 +60,7 @@ from .cycle import (
TransferCycleIssueEndpoint,
CycleIssueGroupedEndpoint,
)
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet
from .asset import FileAssetEndpoint, UserAssetsEndpoint
from .issue import (
IssueViewSet,
IssueListEndpoint,
+7 -15
View File
@@ -4,7 +4,7 @@ from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports
from .base import BaseAPIView, BaseViewSet
from .base import BaseAPIView
from plane.db.models import FileAsset, Workspace
from plane.app.serializers import FileAssetSerializer
@@ -34,20 +34,10 @@ class FileAssetEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, workspace_id, asset_key):
def patch(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class FileAssetViewSet(BaseViewSet):
def restore(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
file_asset.is_deleted = False
file_asset.is_deleted = request.data.get("is_deleted", file_asset.is_deleted)
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -73,6 +63,8 @@ class UserAssetsEndpoint(BaseAPIView):
def delete(self, request, asset_key):
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
file_asset.is_deleted = True
file_asset.save()
# Delete the file from storage
file_asset.asset.delete(save=False)
# Delete the file object
file_asset.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+13 -5
View File
@@ -131,13 +131,21 @@ class ChangePasswordEndpoint(BaseAPIView):
user = User.objects.get(pk=request.user.id)
if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")):
# Check old password
if not user.object.check_password(serializer.data.get("old_password")):
return Response(
{"error": "Old password is not correct"},
{"old_password": ["Wrong password."]},
status=status.HTTP_400_BAD_REQUEST,
)
# set_password also hashes the password that the user will get
user.set_password(serializer.data.get("new_password"))
user.save()
return Response({"message": "Password updated successfully"}, status=status.HTTP_200_OK)
self.object.set_password(serializer.data.get("new_password"))
self.object.save()
response = {
"status": "success",
"code": status.HTTP_200_OK,
"message": "Password updated successfully",
}
return Response(response)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+110 -95
View File
@@ -1,5 +1,4 @@
# Python imports
import os
import uuid
import random
import string
@@ -33,9 +32,7 @@ from plane.db.models import (
)
from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events
def get_tokens_for_user(user):
refresh = RefreshToken.for_user(user)
@@ -49,17 +46,7 @@ class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,)
def post(self, request):
instance_configuration = InstanceConfiguration.objects.values("key", "value")
if (
not get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
if not settings.ENABLE_SIGNUP:
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
@@ -153,8 +140,7 @@ class SignUpEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -162,17 +148,30 @@ class SignUpEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=True
)
try:
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
access_token, refresh_token = get_tokens_for_user(user)
@@ -225,9 +224,15 @@ class SignInEndpoint(BaseAPIView):
},
status=status.HTTP_403_FORBIDDEN,
)
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
# settings last active for the user
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -283,8 +288,7 @@ class SignInEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -292,17 +296,30 @@ class SignInEndpoint(BaseAPIView):
# Delete all the invites
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="EMAIL",
first_time=False
)
try:
# Send Analytics
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "email",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
access_token, refresh_token = get_tokens_for_user(user)
data = {
@@ -343,31 +360,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
def post(self, request):
email = request.data.get("email", False)
instance_configuration = InstanceConfiguration.objects.values("key", "value")
if (
not get_configuration_value(
instance_configuration,
"ENABLE_MAGIC_LINK_LOGIN",
os.environ.get("ENABLE_MAGIC_LINK_LOGIN"),
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
if not email:
return Response(
{"error": "Please provide a valid email address"},
@@ -418,7 +410,8 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
ri.set(key, json.dumps(value), ex=expiry)
current_site = request.META.get("HTTP_ORIGIN")
current_site = request.META.get('HTTP_ORIGIN')
magic_link.delay(email, key, token, current_site)
return Response({"key": key}, status=status.HTTP_200_OK)
@@ -457,18 +450,30 @@ class MagicSignInEndpoint(BaseAPIView):
},
status=status.HTTP_403_FORBIDDEN,
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=False
)
try:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "code",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
else:
user = User.objects.create(
email=email,
@@ -476,20 +481,31 @@ class MagicSignInEndpoint(BaseAPIView):
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
try:
# Send event to Jitsu for tracking
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": "code",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium="MAGIC_LINK",
first_time=True
)
user.is_active = True
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -545,8 +561,7 @@ class MagicSignInEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
+1 -6
View File
@@ -43,25 +43,20 @@ class TimezoneMixin:
class WebhookMixin:
webhook_event = None
bulk = False
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204]
):
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
payload=response.data,
kw=self.kwargs,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
action=self.request.method,
slug=self.workspace_slug,
bulk=self.bulk,
)
return response
+4 -7
View File
@@ -45,23 +45,22 @@ class ConfigurationEndpoint(BaseAPIView):
get_configuration_value(
instance_configuration,
"EMAIL_HOST_USER",
os.environ.get("EMAIL_HOST_USER", None),
os.environ.get("GITHUB_APP_NAME", None),
),
)
and bool(
get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD", None),
os.environ.get("GITHUB_APP_NAME", None),
)
)
) and get_configuration_value(
instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "1"
instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0"
) == "1"
data["email_password_login"] = (
get_configuration_value(
instance_configuration, "ENABLE_EMAIL_PASSWORD", "1"
instance_configuration, "ENABLE_EMAIL_PASSWORD", "0"
)
== "1"
)
@@ -102,6 +101,4 @@ class ConfigurationEndpoint(BaseAPIView):
)
)
data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880))
return Response(data, status=status.HTTP_200_OK)
+3 -6
View File
@@ -502,10 +502,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
class CycleIssueViewSet(WebhookMixin, BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
webhook_event = "cycle_issue"
bulk = True
webhook_event = "cycle"
permission_classes = [
ProjectEntityPermission,
]
@@ -691,6 +688,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
@@ -700,12 +698,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
actor_id=str(self.request.user.id),
issue_id=str(cycle_issue.issue_id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+9 -13
View File
@@ -1,6 +1,6 @@
# Python imports
import requests
import os
# Third party imports
from openai import OpenAI
from rest_framework.response import Response
@@ -27,8 +27,8 @@ class GPTIntegrationEndpoint(BaseAPIView):
# Get the configuration value
instance_configuration = InstanceConfiguration.objects.values("key", "value")
api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY"))
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE", os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"))
api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY")
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE")
# Check the keys
if not api_key or not gpt_engine:
@@ -47,6 +47,10 @@ class GPTIntegrationEndpoint(BaseAPIView):
final_text = task + "\n" + prompt
instance_configuration = InstanceConfiguration.objects.values("key", "value")
gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE")
client = OpenAI(
api_key=api_key,
)
@@ -81,22 +85,14 @@ class ReleaseNotesEndpoint(BaseAPIView):
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
instance_configuration = InstanceConfiguration.objects.values("key", "value")
unsplash_access_key = get_configuration_value(instance_configuration, "UNSPLASH_ACCESS_KEY", os.environ.get("UNSPLASH_ACCESS_KEY"))
# Check unsplash access key
if not unsplash_access_key:
return Response([], status=status.HTTP_200_OK)
# Query parameters
query = request.GET.get("query", False)
page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={unsplash_access_key}&query={query}&page=${page}&per_page={per_page}"
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={unsplash_access_key}&page={page}&per_page={per_page}"
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
)
headers = {
+1 -1
View File
@@ -596,7 +596,7 @@ class IssueActivityEndpoint(BaseAPIView):
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue_comment"
webhook_event = "issue-comment"
permission_classes = [
ProjectLitePermission,
]
+3 -6
View File
@@ -283,12 +283,9 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
webhook_event = "module_issue"
bulk = True
filterset_fields = [
"issue__labels__id",
@@ -464,6 +461,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
)
module_issue.delete()
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
@@ -473,12 +471,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
actor_id=str(request.user.id),
issue_id=str(module_issue.issue_id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+58 -83
View File
@@ -29,10 +29,7 @@ from plane.db.models import (
ProjectMemberInvite,
ProjectMember,
)
from plane.bgtasks.event_tracking_task import auth_events
from .base import BaseAPIView
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
def get_tokens_for_user(user):
@@ -140,40 +137,6 @@ class OauthEndpoint(BaseAPIView):
id_token = request.data.get("credential", False)
client_id = request.data.get("clientId", False)
instance_configuration = InstanceConfiguration.objects.values(
"key", "value"
)
if (
(
not get_configuration_value(
instance_configuration,
"GOOGLE_CLIENT_ID",
os.environ.get("GOOGLE_CLIENT_ID"),
)
or not get_configuration_value(
instance_configuration,
"GITHUB_CLIENT_ID",
os.environ.get("GITHUB_CLIENT_ID"),
)
)
and not (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
if not medium or not id_token:
return Response(
{
@@ -211,7 +174,15 @@ class OauthEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
user.is_active = True
## Login Case
if not user.is_active:
return Response(
{
"error": "Your account has been deactivated. Please contact your site administrator."
},
status=status.HTTP_403_FORBIDDEN,
)
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
@@ -268,8 +239,7 @@ class OauthEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -286,18 +256,29 @@ class OauthEndpoint(BaseAPIView):
"last_login_at": timezone.now(),
},
)
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=False
)
try:
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_IN",
},
)
except RequestException as e:
capture_exception(e)
access_token, refresh_token = get_tokens_for_user(user)
@@ -310,23 +291,6 @@ class OauthEndpoint(BaseAPIView):
except User.DoesNotExist:
## Signup Case
if (
get_configuration_value(
instance_configuration,
"ENABLE_SIGNUP",
os.environ.get("ENABLE_SIGNUP", "0"),
)
and not WorkspaceMemberInvite.objects.filter(
email=request.user.email
).exists()
):
return Response(
{
"error": "New account creation is disabled. Please contact your site administrator"
},
status=status.HTTP_400_BAD_REQUEST,
)
username = uuid.uuid4().hex
if "@" in email:
@@ -409,8 +373,7 @@ class OauthEndpoint(BaseAPIView):
else 15,
member=user,
created_by_id=project_member_invite.created_by_id,
)
for project_member_invite in project_member_invites
) for project_member_invite in project_member_invites
],
ignore_conflicts=True,
)
@@ -418,17 +381,29 @@ class OauthEndpoint(BaseAPIView):
workspace_member_invites.delete()
project_member_invites.delete()
# Send event
if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST:
auth_events.delay(
user=user.id,
email=email,
user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN",
medium=medium.upper(),
first_time=True
)
try:
if settings.ANALYTICS_BASE_API:
_ = requests.post(
settings.ANALYTICS_BASE_API,
headers={
"Content-Type": "application/json",
"X-Auth-Token": settings.ANALYTICS_SECRET_KEY,
},
json={
"event_id": uuid.uuid4().hex,
"event_data": {
"medium": f"oauth-{medium}",
},
"user": {"email": email, "id": str(user.id)},
"device_ctx": {
"ip": request.META.get("REMOTE_ADDR"),
"user_agent": request.META.get("HTTP_USER_AGENT"),
},
"event_type": "SIGN_UP",
},
)
except RequestException as e:
capture_exception(e)
SocialLoginConnection.objects.update_or_create(
medium=medium,
@@ -445,4 +420,4 @@ class OauthEndpoint(BaseAPIView):
"access_token": access_token,
"refresh_token": refresh_token,
}
return Response(data, status=status.HTTP_201_CREATED)
return Response(data, status=status.HTTP_201_CREATED)
+3 -10
View File
@@ -388,7 +388,7 @@ class ProjectInvitationsViewset(BaseViewSet):
{"error": "You cannot invite a user with higher role"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
project_invitations = []
@@ -424,7 +424,7 @@ class ProjectInvitationsViewset(BaseViewSet):
project_invitations = ProjectMemberInvite.objects.bulk_create(
project_invitations, batch_size=10, ignore_conflicts=True
)
current_site = request.META.get("HTTP_ORIGIN")
current_site = request.META.get('HTTP_ORIGIN')
# Send invitations
for invitation in project_invitations:
@@ -469,13 +469,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
workspace_role = workspace_member.role
workspace = workspace_member.workspace
# If the user was already part of workspace
_ = ProjectMember.objects.filter(
workspace__slug=slug,
project_id__in=project_ids,
member=request.user,
).update(is_active=True)
ProjectMember.objects.bulk_create(
[
ProjectMember(
@@ -1047,4 +1040,4 @@ class ProjectDeployBoardViewSet(BaseViewSet):
project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.data, status=status.HTTP_200_OK)
+10 -68
View File
@@ -12,14 +12,11 @@ from plane.app.serializers import (
)
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember
from plane.db.models import User, IssueActivity, WorkspaceMember
from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator
from django.db.models import Q, F, Count, Case, When, Value, IntegerField
class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer
model = User
@@ -48,68 +45,13 @@ class UserEndpoint(BaseViewSet):
def deactivate(self, request):
# Check all workspace user is active
user = self.get_object()
projects_to_deactivate = []
workspaces_to_deactivate = []
projects = ProjectMember.objects.filter(
member=request.user, is_active=True
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
default=0,
output_field=IntegerField(),
)
),
total_members=Count("id"),
)
for project in projects:
if project.other_admin_exists > 0 or (project.total_members == 1):
project.is_active = False
projects_to_deactivate.append(project)
else:
return Response(
{
"error": "You cannot deactivate account as you are the only admin in some projects."
},
status=status.HTTP_400_BAD_REQUEST,
)
workspaces = WorkspaceMember.objects.filter(
member=request.user, is_active=True
).annotate(
other_admin_exists=Count(
Case(
When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1),
default=0,
output_field=IntegerField(),
)
),
total_members=Count("id"),
)
for workspace in workspaces:
if workspace.other_admin_exists > 0 or (workspace.total_members == 1):
workspace.is_active = False
workspaces_to_deactivate.append(workspace)
else:
return Response(
{
"error": "You cannot deactivate account as you are the only admin in some workspaces."
},
status=status.HTTP_400_BAD_REQUEST,
)
ProjectMember.objects.bulk_update(
projects_to_deactivate, ["is_active"], batch_size=100
)
WorkspaceMember.objects.bulk_update(
workspaces_to_deactivate, ["is_active"], batch_size=100
)
if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists():
return Response(
{
"error": "User cannot deactivate account as user is active in some workspaces"
},
status=status.HTTP_400_BAD_REQUEST,
)
# Deactivate the user
user.is_active = False
@@ -134,9 +76,9 @@ class UpdateUserTourCompletedEndpoint(BaseAPIView):
class UserActivityEndpoint(BaseAPIView, BasePaginator):
def get(self, request):
def get(self, request, slug):
queryset = IssueActivity.objects.filter(
actor=request.user
actor=request.user, workspace__slug=slug
).select_related("actor", "workspace", "issue", "project")
return self.paginate(
+2 -4
View File
@@ -20,10 +20,9 @@ class WebhookEndpoint(BaseAPIView):
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
try:
serializer = WebhookSerializer(
data=request.data, context={"request": request}
)
serializer = WebhookSerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -80,7 +79,6 @@ class WebhookEndpoint(BaseAPIView):
serializer = WebhookSerializer(
webhook,
data=request.data,
context={request: request},
partial=True,
fields=(
"id",
+4 -60
View File
@@ -590,7 +590,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
member_with_role=Count(
"project_projectmember",
filter=Q(
project_projectmember__member_id=workspace_member.id,
project_projectmember__member_id=request.user.id,
project_projectmember__role=20,
),
),
@@ -600,7 +600,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin."
"error": "User is part of some projects where they are the only admin you should leave that project first"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -635,7 +635,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin."
"error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -656,7 +656,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin."
"error": "User is part of some projects where they are the only admin you should leave that project first"
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -1313,62 +1313,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
return Response(issues, status=status.HTTP_200_OK)
class WorkspaceUserProfileIssuesGroupedEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
]
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issue_queryset = (
Issue.issue_objects.filter(
Q(assignees__in=[user_id])
| Q(created_by_id=user_id)
| Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug,
project__project_projectmember__member=request.user,
)
.filter(**filters)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.order_by("-created_at")
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class WorkspaceLabelsEndpoint(BaseAPIView):
permission_classes = [
WorkspaceViewerPermission,
@@ -1,7 +1,6 @@
# Python imports
import csv
import io
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
@@ -33,7 +32,7 @@ row_mapping = {
"priority": "Priority",
"estimate": "Estimate",
"issue_cycle__cycle_id": "Cycle",
"issue_module__module_id": "Module",
"issue_module__module_id": "Module"
}
ASSIGNEE_ID = "assignees__id"
@@ -52,48 +51,17 @@ def send_export_email(email, slug, csv_buffer):
csv_buffer.seek(0)
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
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=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
use_ssl=bool(get_configuration_value(instance_configuration, "EMAIL_USE_SSL", "0")),
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False)
@@ -1,6 +1,3 @@
# Python imports
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -17,13 +14,15 @@ from sentry_sdk import capture_exception
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def email_verification(first_name, email, token, current_site):
try:
realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Verify your Email!"
context = {
@@ -36,49 +35,17 @@ def email_verification(first_name, email, token, current_site):
text_content = strip_tags(html_content)
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
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=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email],
connection=connection,
)
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
@@ -1,31 +0,0 @@
import uuid
from posthog import Posthog
from django.conf import settings
#third party imports
from celery import shared_task
from sentry_sdk import capture_exception
@shared_task
def auth_events(user, email, user_agent, ip, event_name, medium, first_time):
print(user, email, user_agent, ip, event_name, medium, first_time)
try:
posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST)
posthog.capture(
email,
event=event_name,
properties={
"event_id": uuid.uuid4().hex,
"user": {"email": email, "id": str(user)},
"device_ctx": {
"ip": ip,
"user_agent": user_agent,
},
"medium": medium,
"first_time": first_time
}
)
except Exception as e:
capture_exception(e)
@@ -1,6 +1,3 @@
# Python import
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -22,6 +19,8 @@ def forgot_password(first_name, email, uidb64, token, current_site):
realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}"
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Reset Your Password - Plane"
context = {
@@ -35,44 +34,14 @@ def forgot_password(first_name, email, uidb64, token, current_site):
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
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"),
)
),
)
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>"),
),
to=[email],
connection=connection,
host=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
@@ -1,6 +1,3 @@
# Python imports
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -15,7 +12,6 @@ from sentry_sdk import capture_exception
from plane.license.models import InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
@shared_task
def magic_link(email, key, token, current_site):
try:
@@ -30,48 +26,17 @@ def magic_link(email, key, token, current_site):
text_content = strip_tags(html_content)
instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_"
).values("key", "value")
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
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=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
)
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>"),
),
to=[email],
connection=connection,
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
@@ -190,7 +190,6 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
issue_activities_created) if issue_activities_created is not None else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
@@ -1,6 +1,3 @@
# Python import
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -28,6 +25,8 @@ def project_invitation(email, project_id, token, current_site, invitor):
relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}"
abs_url = current_site + relativelink
from_email_string = settings.EMAIL_FROM
subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane"
context = {
@@ -49,45 +48,14 @@ def project_invitation(email, project_id, token, current_site, invitor):
# Configure email connection from the database
instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value")
connection = get_connection(
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", 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=get_configuration_value(instance_configuration, "EMAIL_HOST"),
port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"),
use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")),
)
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>"),
),
to=[email],
connection=connection,
)
# Initiate email alternatives
msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
+21 -105
View File
@@ -2,67 +2,15 @@ import requests
import uuid
import hashlib
import json
import hmac
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from plane.db.models import (
Webhook,
WebhookLog,
Project,
Issue,
Cycle,
Module,
ModuleIssue,
CycleIssue,
IssueComment,
)
from plane.api.serializers import (
ProjectSerializer,
IssueSerializer,
CycleSerializer,
ModuleSerializer,
CycleIssueSerializer,
ModuleIssueSerializer,
IssueCommentSerializer,
IssueExpandSerializer,
)
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
"issue": IssueExpandSerializer,
"cycle": CycleSerializer,
"module": ModuleSerializer,
"cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer,
}
MODEL_MAPPER = {
"project": Project,
"issue": Issue,
"cycle": Cycle,
"module": Module,
"cycle_issue": CycleIssue,
"module_issue": ModuleIssue,
"issue_comment": IssueComment,
}
def get_model_data(event, event_id, many=False):
model = MODEL_MAPPER.get(event)
if many:
queryset = model.objects.filter(pk__in=event_id)
else:
queryset = model.objects.get(pk=event_id)
serializer = SERIALIZER_MAPPER.get(event)
return serializer(queryset, many=many).data
from plane.db.models import Webhook, WebhookLog
@shared_task(
@@ -83,24 +31,19 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"X-Plane-Event": event,
}
# # Your secret key
event_data = (
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
if event_data is not None
else None
)
# Use HMAC for generating signature
# Your secret key
if webhook.secret_key:
event_data_json = json.dumps(event_data) if event_data is not None else "{}"
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
event_data_json.encode("utf-8"),
hashlib.sha256,
)
signature = hmac_signature.hexdigest()
# Concatenate the data and the secret key
message = event_data + webhook.secret_key
# Create a SHA-256 hash of the message
sha256 = hashlib.sha256()
sha256.update(message.encode("utf-8"))
signature = sha256.hexdigest()
headers["X-Plane-Signature"] = signature
event_data = json.loads(event_data) if event_data is not None else None
action = {
"POST": "create",
"PATCH": "update",
@@ -153,6 +96,10 @@ def webhook_task(self, webhook, slug, event, event_data, action):
retry_count=str(self.request.retries),
)
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
return
raise requests.RequestException()
except Exception as e:
@@ -163,7 +110,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
@shared_task()
def send_webhook(event, payload, kw, action, slug, bulk):
def send_webhook(event, event_data, action, slug):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@@ -173,48 +120,17 @@ def send_webhook(event, payload, kw, action, slug, bulk):
if event == "issue":
webhooks = webhooks.filter(issue=True)
if event == "module" or event == "module_issue":
if event == "module":
webhooks = webhooks.filter(module=True)
if event == "cycle" or event == "cycle_issue":
if event == "cycle":
webhooks = webhooks.filter(cycle=True)
if event == "issue_comment":
if event == "issue-comment":
webhooks = webhooks.filter(issue_comment=True)
if webhooks:
if action in ["POST", "PATCH"]:
if bulk and event in ["cycle_issue", "module_issue"]:
event_data = IssueExpandSerializer(
Issue.objects.filter(
pk__in=[
str(event.get("issue")) for event in payload
]
).prefetch_related("issue_cycle", "issue_module"), many=True
).data
event = "issue"
action = "PATCH"
else:
event_data = [
get_model_data(
event=event,
event_id=payload.get("id") if isinstance(payload, dict) else None,
many=False,
)
]
if action == "DELETE":
event_data = [{"id": kw.get("pk")}]
for webhook in webhooks:
for data in event_data:
webhook_task.delay(
webhook=webhook.id,
slug=slug,
event=event,
event_data=data,
action=action,
)
for webhook in webhooks:
webhook_task.delay(webhook.id, slug, event, event_data, action)
except Exception as e:
if settings.DEBUG:
@@ -1,6 +1,3 @@
# Python imports
import os
# Django imports
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
@@ -35,6 +32,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
# The complete url including the domain
abs_url = current_site + relative_link
# The email from
from_email_string = settings.EMAIL_FROM
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane"
@@ -58,41 +58,23 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
key__startswith="EMAIL_"
).values("key", "value")
connection = get_connection(
host=get_configuration_value(
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST")
),
host=get_configuration_value(instance_configuration, "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"),
get_configuration_value(instance_configuration, "EMAIL_PORT", "587")
),
username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"),
password=get_configuration_value(
instance_configuration,
"EMAIL_HOST_PASSWORD",
os.environ.get("EMAIL_HOST_PASSWORD"),
instance_configuration, "EMAIL_HOST_PASSWORD"
),
use_tls=bool(
get_configuration_value(
instance_configuration,
"EMAIL_USE_TLS",
os.environ.get("EMAIL_USE_TLS", "1"),
)
get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")
),
)
# Initiate email alternatives
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=get_configuration_value(
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"),
to=[email],
connection=connection,
)
@@ -1,19 +0,0 @@
# Generated by Django 4.2.5 on 2023-11-23 14:57
from django.db import migrations, models
import plane.db.models.workspace
class Migration(migrations.Migration):
dependencies = [
('db', '0051_fileasset_is_deleted'),
]
operations = [
migrations.AlterField(
model_name='workspace',
name='slug',
field=models.SlugField(max_length=48, unique=True, validators=[plane.db.models.workspace.slug_validator]),
),
]
+3 -3
View File
@@ -51,9 +51,9 @@ class Module(ProjectBaseModel):
def save(self, *args, **kwargs):
if self._state.adding:
smallest_sort_order = Module.objects.filter(project=self.project).aggregate(
smallest=models.Min("sort_order")
)["smallest"]
smallest_sort_order = Module.objects.filter(
project=self.project
).aggregate(smallest=models.Min("sort_order"))["smallest"]
if smallest_sort_order is not None:
self.sort_order = smallest_sort_order - 10000
+1
View File
@@ -16,6 +16,7 @@ def generate_token():
def validate_schema(value):
parsed_url = urlparse(value)
print(parsed_url)
if parsed_url.scheme not in ["http", "https"]:
raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.")
+2 -20
View File
@@ -1,7 +1,6 @@
# Django imports
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
# Module imports
from . import BaseModel
@@ -51,7 +50,7 @@ def get_default_props():
"state": True,
"sub_issue_count": True,
"updated_on": True,
},
}
}
@@ -64,23 +63,6 @@ def get_issue_props():
}
def slug_validator(value):
if value in [
"404",
"accounts",
"api",
"create-workspace",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
]:
raise ValidationError("Slug is not valid")
class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True)
@@ -89,7 +71,7 @@ class Workspace(BaseModel):
on_delete=models.CASCADE,
related_name="owner_workspace",
)
slug = models.SlugField(max_length=48, db_index=True, unique=True, validators=[slug_validator,])
slug = models.SlugField(max_length=48, db_index=True, unique=True)
organization_size = models.CharField(max_length=20, blank=True, null=True)
def __str__(self):
@@ -30,11 +30,9 @@ class Command(BaseCommand):
"EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"),
"EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"),
# Open AI Settings
"OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", ""),
"OPENAI_API_BASE": os.environ.get("", "https://api.openai.com/v1"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "sk-"),
"GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
# Unsplash Access Key
"UNSPLASH_ACCESS_KEY": os.environ.get("UNSPLASH_ACESS_KEY", "")
}
for key, value in config_keys.items():
+1 -5
View File
@@ -322,8 +322,4 @@ ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Use Minio settings
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)
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
-1
View File
@@ -36,4 +36,3 @@ scout-apm==2.26.1
openpyxl==3.1.2
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/document-editor",
"version": "0.1.0",
"version": "0.0.1",
"description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
@@ -8,7 +8,6 @@ export const HeadingComp = ({
<h3
onClick={onClick}
className="ml-4 mt-3 cursor-pointer text-sm font-bold font-medium leading-[125%] tracking-tight hover:text-custom-primary max-md:ml-2.5"
role="button"
>
{heading}
</h3>
@@ -24,7 +23,6 @@ export const SubheadingComp = ({
<p
onClick={onClick}
className="ml-6 mt-2 text-xs cursor-pointer font-medium tracking-tight text-gray-400 hover:text-custom-primary"
role="button"
>
{subHeading}
</p>
@@ -18,7 +18,7 @@ export const PageRenderer = (props: IPageRenderer) => {
} = props;
return (
<div className="w-full pl-7 pt-5 pb-64">
<div className="h-full w-full overflow-y-auto pl-7 py-5">
<h1 className="text-4xl font-bold break-all pr-5 -mt-2">
{documentDetails.title}
</h1>
@@ -44,7 +44,7 @@ export const SummaryPopover: React.FC<Props> = (props) => {
</button>
{!sidePeekVisible && (
<div
className="hidden group-hover/summary-popover:block z-10 max-h-80 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 overflow-y-auto"
className="hidden group-hover/summary-popover:block z-10 h-80 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import { getEditorClassNames, useEditor } from "@plane/editor-core";
import { cn, getEditorClassNames, useEditor } from "@plane/editor-core";
import { DocumentEditorExtensions } from "./extensions";
import {
IDuplicationConfig,
@@ -126,8 +126,8 @@ const DocumentEditor = ({
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
/>
<div className="h-full w-full flex overflow-y-auto">
<div className="flex-shrink-0 h-full w-56 lg:w-80 sticky top-0">
<div className="h-full w-full flex overflow-hidden">
<div className="flex-shrink-0 h-full w-56 lg:w-80">
<SummarySideBar
editor={editor}
markings={markings}
@@ -1,4 +1,4 @@
import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import { useRouter } from "next/router";
import { useState, forwardRef, useEffect } from "react";
import { EditorHeader } from "../components/editor-header";
@@ -82,7 +82,7 @@ const DocumentReadOnlyEditor = ({
<EditorHeader
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
readonly
readonly={true}
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
@@ -91,8 +91,8 @@ const DocumentReadOnlyEditor = ({
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
<div className="h-full w-full flex overflow-y-auto">
<div className="flex-shrink-0 h-full w-56 lg:w-80 sticky top-0">
<div className="h-full w-full flex overflow-hidden">
<div className="flex-shrink-0 h-full w-56 lg:w-80">
<SummarySideBar
editor={editor}
markings={markings}
@@ -1,6 +1,6 @@
{
"name": "@plane/lite-text-editor",
"version": "0.1.0",
"version": "0.0.1",
"description": "Package that powers Plane's Comment Editor",
"private": true,
"main": "./dist/index.mjs",
@@ -1,6 +1,6 @@
{
"name": "@plane/rich-text-editor",
"version": "0.1.0",
"version": "0.0.1",
"description": "Rich Text Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -36,8 +36,6 @@ module.exports = {
"custom-sidebar-shadow-xl": "var(--color-sidebar-shadow-xl)",
"custom-sidebar-shadow-2xl": "var(--color-sidebar-shadow-2xl)",
"custom-sidebar-shadow-3xl": "var(--color-sidebar-shadow-3xl)",
"onbording-shadow-sm": "var(--color-onboarding-shadow-sm)",
},
colors: {
custom: {
@@ -194,7 +192,6 @@ module.exports = {
border: {
100: convertToRGB("--color-onboarding-border-100"),
200: convertToRGB("--color-onboarding-border-200"),
300: convertToRGB("--color-onboarding-border-300"),
},
},
},
@@ -375,9 +372,8 @@ module.exports = {
96: "21.6rem",
},
backgroundImage: {
"onboarding-gradient-100": "var( --gradient-onboarding-100)",
"onboarding-gradient-200": "var( --gradient-onboarding-200)",
"onboarding-gradient-300": "var( --gradient-onboarding-300)",
"onboarding-gradient-primary": "var( --gradient-onboarding-primary)",
"onboarding-gradient-secondary": "var( --gradient-onboarding-secondary)",
},
},
fontFamily: {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.1.0",
"version": "0.0.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
-64
View File
@@ -1,64 +0,0 @@
import * as React from "react";
import {
getIconStyling,
getBadgeStyling,
TBadgeVariant,
TBadgeSizes,
} from "./helper";
export interface BadgeProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: TBadgeVariant;
size?: TBadgeSizes;
className?: string;
loading?: boolean;
disabled?: boolean;
appendIcon?: any;
prependIcon?: any;
children: React.ReactNode;
}
const Badge = React.forwardRef<HTMLButtonElement, BadgeProps>((props, ref) => {
const {
variant = "primary",
size = "md",
className = "",
type = "button",
loading = false,
disabled = false,
prependIcon = null,
appendIcon = null,
children,
...rest
} = props;
const buttonStyle = getBadgeStyling(variant, size, disabled || loading);
const buttonIconStyle = getIconStyling(size);
return (
<button
ref={ref}
type={type}
className={`${buttonStyle} ${className}`}
disabled={disabled || loading}
{...rest}
>
{prependIcon && (
<div className={buttonIconStyle}>
{React.cloneElement(prependIcon, { strokeWidth: 2 })}
</div>
)}
{children}
{appendIcon && (
<div className={buttonIconStyle}>
{React.cloneElement(appendIcon, { strokeWidth: 2 })}
</div>
)}
</button>
);
});
Badge.displayName = "plane-ui-badge";
export { Badge };
-145
View File
@@ -1,145 +0,0 @@
export type TBadgeVariant =
| "primary"
| "accent-primary"
| "outline-primary"
| "neutral"
| "accent-neutral"
| "outline-neutral"
| "success"
| "accent-success"
| "outline-success"
| "warning"
| "accent-warning"
| "outline-warning"
| "destructive"
| "accent-destructive"
| "outline-destructive";
export type TBadgeSizes = "sm" | "md" | "lg" | "xl";
export interface IBadgeStyling {
[key: string]: {
default: string;
hover: string;
disabled: string;
};
}
enum badgeSizeStyling {
sm = `px-2.5 py-1 font-medium text-xs rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
md = `px-4 py-1.5 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
lg = `px-4 py-2 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
xl = `px-5 py-3 font-medium text-sm rounded flex items-center gap-1.5 whitespace-nowrap transition-all justify-center inline`,
}
enum badgeIconStyling {
sm = "h-3 w-3 flex justify-center items-center overflow-hidden flex-shrink-0",
md = "h-3.5 w-3.5 flex justify-center items-center overflow-hidden flex-shrink-0",
lg = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0",
xl = "h-4 w-4 flex justify-center items-center overflow-hidden flex-shrink-0",
}
export const badgeStyling: IBadgeStyling = {
primary: {
default: `text-white bg-custom-primary-100`,
hover: `hover:bg-custom-primary-200`,
disabled: `cursor-not-allowed !bg-custom-primary-60 hover:bg-custom-primary-60`,
},
"accent-primary": {
default: `bg-custom-primary-10 text-custom-primary-100`,
hover: `hover:bg-custom-primary-20 hover:text-custom-primary-200`,
disabled: `cursor-not-allowed !text-custom-primary-60`,
},
"outline-primary": {
default: `text-custom-primary-100 bg-custom-background-100 border border-custom-primary-100`,
hover: `hover:border-custom-primary-80 hover:bg-custom-primary-10`,
disabled: `cursor-not-allowed !text-custom-primary-60 !border-custom-primary-60 `,
},
neutral: {
default: `text-custom-background-100 bg-custom-text-100 border border-custom-border-200`,
hover: `hover:bg-custom-text-200`,
disabled: `cursor-not-allowed bg-custom-border-200 !text-custom-text-400`,
},
"accent-neutral": {
default: `text-custom-text-200 bg-custom-background-80`,
hover: `hover:bg-custom-border-200 hover:text-custom-text-100`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
"outline-neutral": {
default: `text-custom-text-200 bg-custom-background-100 border border-custom-border-200`,
hover: `hover:text-custom-text-100 hover:bg-custom-border-200`,
disabled: `cursor-not-allowed !text-custom-text-400`,
},
success: {
default: `text-white bg-green-500`,
hover: `hover:bg-green-600`,
disabled: `cursor-not-allowed !bg-green-300`,
},
"accent-success": {
default: `text-green-500 bg-green-50`,
hover: `hover:bg-green-100 hover:text-green-600`,
disabled: `cursor-not-allowed !text-green-300`,
},
"outline-success": {
default: `text-green-500 bg-custom-background-100 border border-green-500`,
hover: `hover:text-green-600 hover:bg-green-50`,
disabled: `cursor-not-allowed !text-green-300 border-green-300`,
},
warning: {
default: `text-white bg-amber-500`,
hover: `hover:bg-amber-600`,
disabled: `cursor-not-allowed !bg-amber-300`,
},
"accent-warning": {
default: `text-amber-500 bg-amber-50`,
hover: `hover:bg-amber-100 hover:text-amber-600`,
disabled: `cursor-not-allowed !text-amber-300`,
},
"outline-warning": {
default: `text-amber-500 bg-custom-background-100 border border-amber-500`,
hover: `hover:text-amber-600 hover:bg-amber-50`,
disabled: `cursor-not-allowed !text-amber-300 border-amber-300`,
},
destructive: {
default: `text-white bg-red-500`,
hover: `hover:bg-red-600`,
disabled: `cursor-not-allowed !bg-red-300`,
},
"accent-destructive": {
default: `text-red-500 bg-red-50`,
hover: `hover:bg-red-100 hover:text-red-600`,
disabled: `cursor-not-allowed !text-red-300`,
},
"outline-destructive": {
default: `text-red-500 bg-custom-background-100 border border-red-500`,
hover: `hover:text-red-600 hover:bg-red-50`,
disabled: `cursor-not-allowed !text-red-300 border-red-300`,
},
};
export const getBadgeStyling = (
variant: TBadgeVariant,
size: TBadgeSizes,
disabled: boolean = false
): string => {
let _variant: string = ``;
const currentVariant = badgeStyling[variant];
_variant = `${currentVariant.default} ${
disabled ? currentVariant.disabled : currentVariant.hover
}`;
let _size: string = ``;
if (size) _size = badgeSizeStyling[size];
return `${_variant} ${_size}`;
};
export const getIconStyling = (size: TBadgeSizes): string => {
let icon: string = ``;
if (size) icon = badgeIconStyling[size];
return icon;
};
-1
View File
@@ -1 +0,0 @@
export * from "./badge";
+2 -2
View File
@@ -51,11 +51,11 @@ const CustomSelect = (props: ICustomSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 text-xs ${
className={`flex items-center justify-between gap-1 w-full text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
} ${customButtonClassName}`}
>
{customButton}
</button>
-1
View File
@@ -1,6 +1,5 @@
export * from "./avatar";
export * from "./breadcrumbs";
export * from "./badge";
export * from "./button";
export * from "./dropdowns";
export * from "./form-fields";
@@ -17,7 +17,7 @@ export const Spinner: React.FC<ISpinner> = ({
aria-hidden="true"
height={height}
width={width}
className={`animate-spin fill-blue-600 text-custom-text-200 ${className}`}
className={`mr-2 animate-spin fill-blue-600 text-custom-text-200 ${className}`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -43,11 +43,7 @@ export const IssueListBlock = observer(({ issue }: { issue: IIssue }) => {
</div>
{/* name */}
<h6
onClick={handleBlockClick}
role="button"
className="text-sm font-medium break-words line-clamp-2 cursor-pointer"
>
<h6 onClick={handleBlockClick} className="text-sm font-medium break-words line-clamp-2 cursor-pointer">
{issue.name}
</h6>
+1 -6
View File
@@ -1,6 +1 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : "";
+3 -4
View File
@@ -17,10 +17,9 @@
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.13",
"@mui/material": "^5.14.1",
"@plane/ui": "*",
"@plane/lite-text-editor": "*",
"@plane/rich-text-editor": "*",
"@plane/ui": "*",
"@plane/document-editor": "*",
"axios": "^1.3.4",
"clsx": "^2.0.0",
"js-cookie": "^3.0.1",
@@ -36,7 +35,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.38.0",
"swr": "^2.2.2",
"tailwind-merge": "^2.0.0",
"tailwind-merge": "^1.14.0",
"typescript": "4.9.5",
"uuid": "^9.0.0"
},
@@ -44,7 +43,7 @@
"@types/js-cookie": "^3.0.3",
"@types/node": "18.14.1",
"@types/nprogress": "^0.2.0",
"@types/react": "18.2.35",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.2",
@@ -1,53 +1,38 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
import { mutate } from "swr";
import { useTheme } from "next-themes";
import { Dialog, Transition } from "@headlessui/react";
import { AlertTriangle } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
// components
import { Button } from "@plane/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import { AuthService } from "services/auth.service";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// icons
import { AlertTriangle } from "lucide-react";
import { UserService } from "services/user.service";
type Props = {
isOpen: boolean;
onClose: () => void;
};
export const DeactivateAccountModal: React.FC<Props> = (props) => {
const authService = new AuthService();
const userService = new UserService();
const DeleteAccountModal: React.FC<Props> = (props) => {
const { isOpen, onClose } = props;
// states
const [switchingAccount, setSwitchingAccount] = useState(false);
const [isDeactivating, setIsDeactivating] = useState(false);
const {
user: { deactivateAccount, signOut },
} = useMobxStore();
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter();
const { setTheme } = useTheme();
const { setToastAlert } = useToast();
const handleClose = () => {
setSwitchingAccount(false);
setIsDeactivating(false);
onClose();
};
const handleSwitchAccount = async () => {
setSwitchingAccount(true);
await signOut()
const handleSignOut = async () => {
await authService
.signOut()
.then(() => {
mutate("CURRENT_USER_DETAILS", null);
setTheme("system");
router.push("/");
handleClose();
})
.catch(() =>
setToastAlert({
@@ -55,31 +40,33 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
title: "Error!",
message: "Failed to sign out. Please try again.",
})
)
.finally(() => setSwitchingAccount(false));
);
};
const handleDeleteAccount = async () => {
setIsDeactivating(true);
await deactivateAccount()
setIsDeleteLoading(true);
await userService
.deleteAccount()
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Account deleted successfully.",
});
handleClose();
router.push("/");
})
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: err?.error,
message: err?.data?.error,
})
)
.finally(() => setIsDeactivating(false));
);
setIsDeleteLoading(false);
};
const handleClose = () => {
onClose();
};
return (
@@ -112,29 +99,32 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="">
<div className="flex items-center gap-x-4">
<div className="grid place-items-center rounded-full bg-red-500/20 p-4">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
</div>
<Dialog.Title as="h3" className="text-2xl font-medium leading-6 text-onboarding-text-100">
Deactivate account?
Not the right workspace?
</Dialog.Title>
</div>
<div className="mt-6 px-4">
<ul className="text-onboarding-text-300 list-disc font-normal text-base">
<li>Deactivate this account if you have another and won{"'"}t use this account.</li>
<li>Switch to another account if you{"'"}d like to come back to this account another time.</li>
<li>Delete this account if you have another and wont use this account.</li>
<li>Switch to another account if youd like to come back to this account another time.</li>
</ul>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 p-4 mb-2 sm:px-6">
<Button variant="link-primary" onClick={handleSwitchAccount} loading={switchingAccount}>
{switchingAccount ? "Switching..." : "Switch account"}
</Button>
<Button variant="outline-danger" onClick={handleDeleteAccount}>
{isDeactivating ? "Deactivating..." : "Deactivate account"}
</Button>
<div className="flex items-center justify-end gap-4 p-4 mb-2 sm:px-6">
<span className="text-sm font-medium hover:cursor-pointer" onClick={handleSignOut}>
Switch account
</span>
<button
className="py-1.5 px-3 font-medium rounded-sm text-red-500 border border-red-500 text-sm "
onClick={handleDeleteAccount}
>
{isDeleteLoading ? "Deleting..." : "Delete account"}
</button>
</div>
</Dialog.Panel>
</Transition.Child>
@@ -144,3 +134,5 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
</Transition.Root>
);
};
export default DeleteAccountModal;
+16 -56
View File
@@ -1,16 +1,17 @@
import React, { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
// ui
import { Button, Input } from "@plane/ui";
// components
import { AuthType } from "components/page-views";
// services
import { AuthService } from "services/auth.service";
// hooks
import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer";
// icons
import { XCircle } from "lucide-react";
import { useTheme } from "next-themes";
// types
type EmailCodeFormValues = {
email: string;
key?: string;
@@ -19,14 +20,7 @@ type EmailCodeFormValues = {
const authService = new AuthService();
type Props = {
handleSignIn: any;
authType: AuthType;
};
export const EmailCodeForm: React.FC<Props> = (Props) => {
const { handleSignIn, authType } = Props;
// states
export const EmailCodeForm = ({ handleSignIn }: any) => {
const [codeSent, setCodeSent] = useState(false);
const [codeResent, setCodeResent] = useState(false);
const [isCodeResending, setIsCodeResending] = useState(false);
@@ -43,6 +37,7 @@ export const EmailCodeForm: React.FC<Props> = (Props) => {
setError,
setValue,
getValues,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailCodeFormValues>({
defaultValues: {
@@ -54,13 +49,14 @@ export const EmailCodeForm: React.FC<Props> = (Props) => {
reValidateMode: "onChange",
});
const isResendDisabled = resendCodeTimer > 0 || isCodeResending || isSubmitting;
const isResendDisabled = resendCodeTimer > 0 || isCodeResending || isSubmitting || errorResendingCode;
const onSubmit = async ({ email }: EmailCodeFormValues) => {
setErrorResendingCode(false);
await authService
.emailCode({ email })
.then((res) => {
console.log(res);
setSentEmail(email);
setValue("key", res.key);
setCodeSent(true);
@@ -143,20 +139,12 @@ export const EmailCodeForm: React.FC<Props> = (Props) => {
) : (
<>
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-onboarding-text-100">
{authType === "sign-in" ? "Get on your flight deck!" : "Lets get you prepped!"}
Lets get you prepped!
</h1>
{authType == "sign-up" ? (
<div>
<p className="text-center text-sm text-onboarding-text-200 mt-3">
This whole thing will take less than two minutes.
</p>
<p className="text-center text-sm text-onboarding-text-200 mt-1">Promise!</p>
</div>
) : (
<p className="text-center text-sm text-onboarding-text-200 px-20 mt-3">
Sign in with the email you used to sign up for Plane
</p>
)}
<p className="text-center text-sm text-onboarding-text-200 mt-3">
This whole thing will take less than two minutes.
</p>
<p className="text-center text-sm text-onboarding-text-200 mt-1">Promise!</p>
</>
)}
@@ -228,39 +216,11 @@ export const EmailCodeForm: React.FC<Props> = (Props) => {
onChange={onChange}
ref={ref}
hasError={Boolean(errors.token)}
placeholder="gets-sets-flys"
placeholder="get-set-fly"
className="border-onboarding-border-100 h-[46px] w-full"
/>
)}
/>
{resendCodeTimer <= 0 && !isResendDisabled && (
<button
type="button"
className={`flex absolute w-fit right-3.5 justify-end text-xs outline-none cursor-pointer text-custom-primary-100`}
onClick={() => {
setIsCodeResending(true);
onSubmit({ email: getValues("email") }).then(() => {
setCodeResent(true);
setIsCodeResending(false);
setResendCodeTimer(30);
});
}}
disabled={isResendDisabled}
>
<span className="font-medium">Resend</span>
</button>
)}
</div>
<div
className={`flex w-full justify-end text-xs outline-none ${
isResendDisabled ? "cursor-default text-custom-text-200" : "cursor-pointer text-custom-primary-100"
} `}
>
{resendCodeTimer > 0 ? (
<span className="text-right">Request new code in {resendCodeTimer}s</span>
) : isCodeResending ? (
"Sending new code..."
) : null}
</div>
</>
)}
@@ -278,8 +238,8 @@ export const EmailCodeForm: React.FC<Props> = (Props) => {
>
{isLoading ? "Signing in..." : "Next step"}
</Button>
<div className="w-3/4 my-4 mx-auto">
<p className="text-xs text-center text-onboarding-text-300">
<div className="w-[70%] my-4 mx-auto">
<p className="text-xs text-onboarding-text-300">
When you click the button above, you agree with our{" "}
<a
href="https://plane.so/terms-and-conditions"
+8 -11
View File
@@ -6,18 +6,16 @@ import Image from "next/image";
import { useRouter } from "next/router";
import { useTheme } from "next-themes";
// images
import githubLightModeImage from "/public/logos/github-black.png";
import githubDarkModeImage from "/public/logos/github-dark.svg";
import { AuthType } from "components/page-views";
import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png";
export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>;
clientId: string;
authType: AuthType;
}
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const { handleSignIn, clientId, authType } = props;
const { handleSignIn, clientId } = props;
// states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
const [gitCode, setGitCode] = useState<null | string>(null);
@@ -26,7 +24,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
query: { code },
} = useRouter();
// theme
const { resolvedTheme } = useTheme();
const { theme } = useTheme();
useEffect(() => {
if (code && !gitCode) {
@@ -39,23 +37,22 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/` as any);
}, []);
return (
<div className="w-full flex justify-center items-center">
<Link
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
>
<button
className={`flex w-full items-center justify-center gap-2 hover:bg-onboarding-background-300 rounded border px-2 text-sm font-medium text-custom-text-100 duration-300 h-[42px] ${
resolvedTheme === "dark" ? "bg-[#2F3135] border-[#43484F]" : "border-[#D9E4FF]"
}`}
className={`flex w-full items-center justify-center gap-2 hover:bg-onboarding-background-300 rounded border border-onboarding-border-200 p-2 text-sm font-medium text-custom-text-100 duration-300 h-[46px]`}
>
<Image
src={resolvedTheme === "dark" ? githubDarkModeImage : githubLightModeImage}
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
height={20}
width={20}
alt="GitHub Logo"
/>
<span className="text-onboarding-text-200">{authType == "sign-in" ? "Sign-in" : "Sign-up"} with GitHub</span>
<span className="text-onboarding-text-200">Sign in with GitHub</span>
</button>
</Link>
</div>
-1
View File
@@ -1,4 +1,3 @@
export * from "./deactivate-account-modal";
export * from "./email-code-form";
export * from "./email-password-form";
export * from "./email-forgot-password-form";
@@ -1,7 +1,12 @@
import React, { useEffect } from "react";
import { useTheme } from "next-themes";
import Image from "next/image";
import { Avatar, DiceIcon, PhotoFilterIcon } from "@plane/ui";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// react-hook-form
import { Control, Controller, UseFormSetValue, UseFormWatch } from "react-hook-form";
// types
import { IWorkspace } from "types";
// icons
import {
BarChart2,
Briefcase,
@@ -14,16 +19,7 @@ import {
PenSquare,
Search,
Settings,
Bell,
} from "lucide-react";
import { Avatar, DiceIcon, PhotoFilterIcon } from "@plane/ui";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { IWorkspace } from "types";
// assets
import projectEmoji from "public/emoji/project-emoji.svg";
const workspaceLinks = [
{
@@ -43,7 +39,7 @@ const workspaceLinks = [
name: "All Issues",
},
{
Icon: Bell,
Icon: CheckCircle,
name: "Notifications",
},
];
@@ -85,36 +81,30 @@ type Props = {
control?: Control<IWorkspace, any>;
setValue?: UseFormSetValue<IWorkspace>;
watch?: UseFormWatch<IWorkspace>;
userFullName?: string;
};
var timer: number = 0;
var lastWorkspaceName: string = "";
export const OnboardingSidebar: React.FC<Props> = (props) => {
const { workspaceName, showProject, control, setValue, watch, userFullName } = props;
const {
workspace: workspaceStore,
user: { currentUser },
} = useMobxStore();
const DummySidebar: React.FC<Props> = (props) => {
const { workspaceName, showProject, control, setValue, watch } = props;
const { workspace: workspaceStore, user: userStore } = useMobxStore();
const workspace = workspaceStore.workspaces ? workspaceStore.workspaces[0] : null;
const { resolvedTheme } = useTheme();
const handleZoomWorkspace = (value: string) => {
// console.log(lastWorkspaceName,value);
if (lastWorkspaceName === value) return;
lastWorkspaceName = value;
if (timer > 0) {
timer += 2;
timer = Math.min(timer, 2);
timer = Math.min(timer, 4);
} else {
timer = 2;
timer = Math.min(timer, 2);
timer = Math.min(timer, 4);
const interval = setInterval(() => {
if (timer < 0) {
setValue!("name", lastWorkspaceName);
clearInterval(interval);
}
console.log("timer", timer);
timer--;
}, 1000);
}
@@ -122,7 +112,7 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
useEffect(() => {
if (watch) {
watch("name");
watch();
}
});
@@ -136,46 +126,33 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
render={({ field: { value } }) => {
if (value.length > 0) {
handleZoomWorkspace(value);
} else {
lastWorkspaceName = "";
}
return timer > 0 ? (
<div
className={`top-3 mt-4 transition-all bg-onboarding-background-200 w-full max-w-screen-sm flex items-center ml-6 border-[6px] ${
resolvedTheme == "dark" ? "border-onboarding-background-100" : "border-custom-primary-20"
} rounded-xl`}
>
<div className="border rounded-lg py-6 pl-4 w-full border-onboarding-background-400">
<div
className={`${
resolvedTheme == "light" ? "bg-[#F5F5F5]" : "bg-[#363A40]"
} w-full p-1 flex items-center`}
>
<div className="flex flex-shrink-0">
<Avatar
name={value.length > 0 ? value : "New Workspace"}
src={""}
size={30}
shape="square"
fallbackBackgroundColor="black"
className="!text-base capitalize"
/>
</div>
<span className="text-xl font-medium text-onboarding-text-100 ml-2 truncate">{value}</span>
<div className="py-6 pl-4 top-3 mt-4 transition-all bg-onboarding-background-200 w-full max-w-screen-sm flex items-center ml-6 border-8 border-onboarding-background-100 rounded-md">
<div className="bg-onboarding-background-100 w-full p-1 flex items-center">
<div className="flex flex-shrink-0">
<Avatar
name={value.length > 0 ? value[0].toLocaleUpperCase() : "N"}
src={""}
size={30}
shape="square"
fallbackBackgroundColor="black"
className="!text-base"
/>
</div>
<span className="text-xl font-medium text-onboarding-text-100 ml-2 truncate">{value}</span>
</div>
</div>
) : (
<div className="flex transition-all w-full border border-transparent items-center gap-y-2 px-4 pt-6 truncate">
<div className="flex flex-shrink-0">
<Avatar
name={value.length > 0 ? value : workspace ? workspace.name : "New Workspace"}
name={value.length > 0 ? value : workspace ? workspace.name[0].toLocaleUpperCase() : "N"}
src={""}
size={24}
shape="square"
fallbackBackgroundColor="black"
className="!text-base capitalize"
className="!text-base"
/>
</div>
<div className="w-full mx-2 items-center flex justify-between flex-shrink truncate">
@@ -184,12 +161,12 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
</div>
<div className="flex flex-shrink-0">
<Avatar
name={currentUser?.email}
src={currentUser?.avatar}
name={"N"}
src={workspace && workspace.logo ? workspace.logo : ""}
size={24}
shape="square"
fallbackBackgroundColor="#FCBE1D"
className="!text-base capitalize"
className="!text-base"
/>
</div>
</div>
@@ -200,12 +177,11 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
<div className="flex transition-all w-full items-center gap-y-2 px-4 pt-6 truncate">
<div className="flex flex-shrink-0">
<Avatar
name={workspace ? workspace.name : "New Workspace"}
name={workspace ? workspace.name[0].toLocaleUpperCase() : "N"}
src={""}
size={24}
shape="square"
fallbackBackgroundColor="black"
className="!text-base capitalize"
className="!text-base"
/>
</div>
<div className="w-full mx-2 items-center flex justify-between flex-shrink truncate">
@@ -214,12 +190,12 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
</div>
<div className="flex flex-shrink-0">
<Avatar
name={userFullName ?? currentUser?.email}
src={currentUser?.avatar}
name={"N"}
src={workspace && workspace.logo ? workspace.logo : ""}
size={24}
shape="square"
fallbackBackgroundColor="#FCBE1D"
className="!text-base capitalize"
className="!text-base"
/>
</div>
</div>
@@ -230,7 +206,7 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
<div className={`flex items-center justify-between w-full px-1 mb-3 gap-2 mt-4 `}>
<div
className={`relative flex items-center justify-between w-full rounded gap-1 group
px-3 shadow-custom-shadow-2xs border-onboarding-border-100 border
px-3 shadow-custom-sidebar-shadow-2xs border-[0.5px] border-custom-border-200
`}
>
<div className={`relative flex items-center gap-2 flex-grow rounded flex-shrink-0 py-1.5 outline-none`}>
@@ -241,7 +217,7 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
<div
className={`flex items-center justify-center rounded flex-shrink-0 p-2 outline-none
shadow-custom-shadow-2xs border border-onboarding-border-100
shadow-custom-sidebar-shadow-2xs border-[0.5px] border-onboarding-border-200
`}
>
<Search className="h-4 w-4 text-onboarding-text-200" />
@@ -268,15 +244,11 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
<div className="px-3">
{" "}
<div className="w-4/5 flex items-center text-base font-medium text-custom-text-200 mb-3 justify-between">
<div className="flex items-center gap-x-2">
<Image src={projectEmoji} alt="Plane Logo" className="h-4 w-4" />
<span> Plane</span>
</div>
<span> Plane web</span>
<ChevronDown className="h-4 w-4" />
</div>
{projectLinks.map((link) => (
<a className="block ml-6 w-full">
<a className="block w-full">
<div
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-base font-medium outline-none
text-custom-sidebar-text-200 focus:bg-custom-sidebar-background-80
@@ -293,3 +265,5 @@ export const OnboardingSidebar: React.FC<Props> = (props) => {
</div>
);
};
export default DummySidebar;
@@ -1,19 +1,21 @@
import React from "react";
export const OnboardingStepIndicator = ({ step }: { step: number }) => (
const OnboardingStepIndicator = ({ step }: { step: number }) => (
<div className="flex items-center justify-center">
<div className="h-3 w-3 rounded-full bg-custom-primary-100 z-10" />
<div className="h-4 w-4 rounded-full bg-custom-primary-100 z-10" />
<div className={`h-1 w-14 -ml-1 ${step >= 2 ? "bg-custom-primary-100" : "bg-onboarding-background-100"}`} />
<div
className={` z-10 -ml-1 rounded-full ${
step >= 2 ? "bg-custom-primary-100 h-3 w-3" : " h-2 w-2 bg-onboarding-background-100"
step >= 2 ? "bg-custom-primary-100 h-4 w-4" : " h-3 w-3 bg-onboarding-background-100"
}`}
/>
<div className={`h-1 w-14 -ml-1 ${step >= 3 ? "bg-custom-primary-100" : "bg-onboarding-background-100"}`} />
<div
className={`rounded-full -ml-1 z-10 ${
step >= 3 ? "bg-custom-primary-100 h-3 w-3" : "h-2 w-2 bg-onboarding-background-100"
step >= 3 ? "bg-custom-primary-100 h-4 w-4" : "h-3 w-3 bg-onboarding-background-100"
}`}
/>
</div>
);
export default OnboardingStepIndicator;
@@ -0,0 +1,55 @@
import { TextArea } from "@plane/ui";
import { Control, Controller, FieldErrors } from "react-hook-form";
import { IApiToken } from "types/api_token";
import { IApiFormFields } from "./types";
import { Dispatch, SetStateAction } from "react";
interface IApiTokenDescription {
generatedToken: IApiToken | null | undefined;
control: Control<IApiFormFields, any>;
focusDescription: boolean;
setFocusTitle: Dispatch<SetStateAction<boolean>>;
setFocusDescription: Dispatch<SetStateAction<boolean>>;
}
export const ApiTokenDescription = ({
generatedToken,
control,
focusDescription,
setFocusTitle,
setFocusDescription,
}: IApiTokenDescription) => (
<Controller
control={control}
name="description"
render={({ field: { value, onChange } }) =>
focusDescription ? (
<TextArea
id="description"
name="description"
autoFocus={true}
onBlur={() => {
setFocusDescription(false);
}}
value={value}
defaultValue={value}
onChange={onChange}
placeholder="Description"
className="mt-3"
rows={3}
/>
) : (
<p
onClick={() => {
if (generatedToken != null) return;
setFocusTitle(false);
setFocusDescription(true);
}}
className={`${value.length === 0 ? "text-custom-text-400/60" : "text-custom-text-300"} text-lg pt-3`}
>
{value.length != 0 ? value : "Description"}
</p>
)
}
/>
);
@@ -0,0 +1,110 @@
import { Menu, Transition } from "@headlessui/react";
import { ToggleSwitch } from "@plane/ui";
import { Dispatch, Fragment, SetStateAction } from "react";
import { Control, Controller } from "react-hook-form";
import { IApiFormFields } from "./types";
interface IApiTokenExpiry {
neverExpires: boolean;
selectedExpiry: number;
setSelectedExpiry: Dispatch<SetStateAction<number>>;
setNeverExpire: Dispatch<SetStateAction<boolean>>;
renderExpiry: () => string;
control: Control<IApiFormFields, any>;
}
export const expiryOptions = [
{
title: "7 Days",
days: 7,
},
{
title: "30 Days",
days: 30,
},
{
title: "1 Month",
days: 30,
},
{
title: "3 Months",
days: 90,
},
{
title: "1 Year",
days: 365,
},
];
export const ApiTokenExpiry = ({
neverExpires,
selectedExpiry,
setSelectedExpiry,
setNeverExpire,
renderExpiry,
control,
}: IApiTokenExpiry) => (
<>
<Menu>
<p className="text-sm font-medium mb-2"> Expiration Date</p>
<Menu.Button className={"w-[40%]"} disabled={neverExpires}>
<div className="py-3 w-full font-medium px-3 flex border border-custom-border-200 rounded-md justify-center items-baseline">
<p className={`text-base ${neverExpires ? "text-custom-text-400/40" : ""}`}>
{expiryOptions[selectedExpiry].title.toLocaleLowerCase()}
</p>
<p className={`text-sm mr-auto ml-2 text-custom-text-400${neverExpires ? "/40" : ""}`}>({renderExpiry()})</p>
</div>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute z-10 overflow-y-scroll whitespace-nowrap rounded-sm max-h-36 border origin-top-right mt-1 overflow-auto min-w-[10rem] border-custom-border-100 p-1 shadow-lg focus:outline-none bg-custom-background-100">
{expiryOptions.map((option, index) => (
<Menu.Item key={index}>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedExpiry(index);
}}
className={`w-full text-sm select-none truncate rounded px-3 py-1.5 text-left text-custom-text-300 hover:bg-custom-background-80 ${
active ? "bg-custom-background-80" : ""
}`}
>
{option.title}
</button>
</div>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
<div className="mt-4 mb-6 flex items-center">
<span className="text-sm font-medium"> Never Expires</span>
<Controller
control={control}
name="never_expires"
render={({ field: { onChange, value } }) => (
<ToggleSwitch
className="ml-3"
value={value}
onChange={(val) => {
onChange(val);
setNeverExpire(val);
}}
size="sm"
/>
)}
/>
</div>
</>
);
@@ -0,0 +1,53 @@
import { Button } from "@plane/ui";
import useToast from "hooks/use-toast";
import { Copy } from "lucide-react";
import { Dispatch, SetStateAction } from "react";
import { IApiToken } from "types/api_token";
interface IApiTokenKeySection {
generatedToken: IApiToken | null | undefined;
renderExpiry: () => string;
setDeleteTokenModal: Dispatch<SetStateAction<boolean>>;
}
export const ApiTokenKeySection = ({ generatedToken, renderExpiry, setDeleteTokenModal }: IApiTokenKeySection) => {
const { setToastAlert } = useToast();
return generatedToken ? (
<div className={`mt-${generatedToken ? "8" : "16"}`}>
<p className="font-medium text-base pb-2">Api key created successfully</p>
<p className="text-sm pb-4 w-[80%] text-custom-text-400/60">
Save this API key somewhere safe. You will not be able to view it again once you close this page or reload this
page.
</p>
<Button variant="neutral-primary" className="py-3 w-[85%] flex justify-between items-center">
<p className="font-medium text-base">{generatedToken.token}</p>
<Copy
size={18}
color="#B9B9B9"
onClick={() => {
navigator.clipboard.writeText(generatedToken.token);
setToastAlert({
message: "The Secret key has been successfully copied to your clipboard",
type: "success",
title: "Copied to clipboard",
});
}}
/>
</Button>
<p className="mt-2 text-sm text-custom-text-400/60">
{generatedToken.expired_at ? "Expires on " + renderExpiry() : "Never Expires"}
</p>
<button
className="border py-3 px-5 text-custom-primary-100 text-sm mt-8 rounded-md border-custom-primary-100 w-fit font-medium"
onClick={(e) => {
e.preventDefault();
setDeleteTokenModal(true);
}}
>
Revoke
</button>
</div>
) : null;
};
@@ -0,0 +1,69 @@
import { Input } from "@plane/ui";
import { Dispatch, SetStateAction } from "react";
import { Control, Controller, FieldErrors } from "react-hook-form";
import { IApiToken } from "types/api_token";
import { IApiFormFields } from "./types";
interface IApiTokenTitle {
generatedToken: IApiToken | null | undefined;
errors: FieldErrors<IApiFormFields>;
control: Control<IApiFormFields, any>;
focusTitle: boolean;
setFocusTitle: Dispatch<SetStateAction<boolean>>;
setFocusDescription: Dispatch<SetStateAction<boolean>>;
}
export const ApiTokenTitle = ({
generatedToken,
errors,
control,
focusTitle,
setFocusTitle,
setFocusDescription,
}: IApiTokenTitle) => (
<Controller
control={control}
name="title"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) =>
focusTitle ? (
<Input
id="title"
name="title"
type="text"
inputSize="md"
onBlur={() => {
setFocusTitle(false);
}}
onError={() => {
console.log("error");
}}
autoFocus={true}
value={value}
onChange={onChange}
ref={ref}
hasError={!!errors.title}
placeholder="Title"
className="resize-none text-xl w-full"
/>
) : (
<p
onClick={() => {
if (generatedToken != null) return;
setFocusDescription(false);
setFocusTitle(true);
}}
className={`${value.length === 0 ? "text-custom-text-400/60" : ""} font-medium text-[24px]`}
>
{value.length != 0 ? value : "Api Title"}
</p>
)
}
/>
);
@@ -0,0 +1,143 @@
import { useForm } from "react-hook-form";
import { addDays, renderDateFormat } from "helpers/date-time.helper";
import { IApiToken } from "types/api_token";
import { csvDownload } from "helpers/download.helper";
import { useRouter } from "next/router";
import { Dispatch, SetStateAction, useState } from "react";
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
import { ApiTokenService } from "services/api_token.service";
import { ApiTokenTitle } from "./ApiTokenTitle";
import { ApiTokenDescription } from "./ApiTokenDescription";
import { ApiTokenExpiry, expiryOptions } from "./ApiTokenExpiry";
import { Button } from "@plane/ui";
import { ApiTokenKeySection } from "./ApiTokenKeySection";
interface IApiTokenForm {
generatedToken: IApiToken | null | undefined;
setGeneratedToken: Dispatch<SetStateAction<IApiToken | null | undefined>>;
setDeleteTokenModal: Dispatch<SetStateAction<boolean>>;
}
const apiTokenService = new ApiTokenService();
export const ApiTokenForm = ({ generatedToken, setGeneratedToken, setDeleteTokenModal }: IApiTokenForm) => {
const [loading, setLoading] = useState<boolean>(false);
const [neverExpires, setNeverExpire] = useState<boolean>(false);
const [focusTitle, setFocusTitle] = useState<boolean>(false);
const [focusDescription, setFocusDescription] = useState<boolean>(false);
const [selectedExpiry, setSelectedExpiry] = useState<number>(1);
const { setToastAlert } = useToast();
const { theme: themStore } = useMobxStore();
const router = useRouter();
const { workspaceSlug } = router.query;
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: {
never_expires: false,
title: "",
description: "",
},
});
const getExpiryDate = (): string | null => {
if (neverExpires === true) return null;
return addDays({ date: new Date(), days: expiryOptions[selectedExpiry].days }).toISOString();
};
function renderExpiry(): string {
return renderDateFormat(addDays({ date: new Date(), days: expiryOptions[selectedExpiry].days }), true);
}
const downloadSecretKey = (token: IApiToken) => {
const csvData = {
Label: token.label,
Description: token.description,
Expiry: renderDateFormat(token.expired_at ?? null),
"Secret Key": token.token,
};
csvDownload(csvData, `Secret-key-${Date.now()}`);
};
const generateToken = async (data: any) => {
if (!workspaceSlug) return;
setLoading(true);
await apiTokenService
.createApiToken(workspaceSlug.toString(), {
label: data.title,
description: data.description,
expired_at: getExpiryDate(),
})
.then((res) => {
setGeneratedToken(res);
downloadSecretKey(res);
setLoading(false);
})
.catch((err) => {
setToastAlert({
message: err.message,
type: "error",
title: "Error",
});
});
};
return (
<form
onSubmit={handleSubmit(generateToken, (err) => {
if (err.title) {
setFocusTitle(true);
}
})}
className={`${themStore.sidebarCollapsed ? "xl:w-[50%] lg:w-[60%] " : "w-[60%]"} mx-auto py-8`}
>
<div className="border-b border-custom-border-200 pb-4">
<ApiTokenTitle
generatedToken={generatedToken}
control={control}
errors={errors}
focusTitle={focusTitle}
setFocusTitle={setFocusTitle}
setFocusDescription={setFocusDescription}
/>
{errors.title && focusTitle && <p className=" text-red-600">{errors.title.message}</p>}
<ApiTokenDescription
generatedToken={generatedToken}
control={control}
focusDescription={focusDescription}
setFocusTitle={setFocusTitle}
setFocusDescription={setFocusDescription}
/>
</div>
{!generatedToken && (
<div className="mt-12">
<>
<ApiTokenExpiry
neverExpires={neverExpires}
selectedExpiry={selectedExpiry}
setSelectedExpiry={setSelectedExpiry}
setNeverExpire={setNeverExpire}
renderExpiry={renderExpiry}
control={control}
/>
<Button variant="primary" type="submit">
{loading ? "generating..." : "Add Api key"}
</Button>
</>
</div>
)}
<ApiTokenKeySection
generatedToken={generatedToken}
renderExpiry={renderExpiry}
setDeleteTokenModal={setDeleteTokenModal}
/>
</form>
);
};
@@ -0,0 +1,5 @@
export interface IApiFormFields {
never_expires: boolean;
title: string;
description: string;
}
@@ -0,0 +1,43 @@
import Link from "next/link";
// helpers
import { formatLongDateDistance, timeAgo } from "helpers/date-time.helper";
// icons
import { XCircle } from "lucide-react";
import { IApiToken } from "types/api_token";
interface IApiTokenListItem {
workspaceSlug: string | string[] | undefined;
token: IApiToken;
}
export const ApiTokenListItem = ({ token, workspaceSlug }: IApiTokenListItem) => (
<Link href={`/${workspaceSlug}/settings/api-tokens/${token.id}`} key={token.id}>
<div className="border-b flex flex-col relative justify-center items-start border-custom-border-200 py-5 hover:cursor-pointer">
<XCircle className="absolute right-5 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto justify-self-center stroke-custom-text-400 h-[15px] w-[15px]" />
<div className="flex items-center px-4">
<span className="text-sm font-medium leading-6">{token.label}</span>
<span
className={`${
token.is_active ? "bg-green-600/10 text-green-600" : "bg-custom-text-400/20 text-custom-text-400"
} flex items-center px-2 h-4 rounded-sm max-h-fit ml-2 text-xs font-medium`}
>
{token.is_active ? "Active" : "Expired"}
</span>
</div>
<div className="flex items-center px-4 w-full">
{token.description.length != 0 && (
<p className="text-sm mb-1 mr-3 font-medium leading-6 truncate max-w-[50%]">{token.description}</p>
)}
{
<p className="text-xs mb-1 leading-6 text-custom-text-400">
{token.is_active
? token.expired_at === null
? "Never Expires"
: `Expires in ${formatLongDateDistance(token.expired_at!)}`
: timeAgo(token.expired_at)}
</p>
}
</div>
</div>
</Link>
);
+39 -54
View File
@@ -1,71 +1,56 @@
//react
import { useState, Fragment, FC } from "react";
//next
import { useRouter } from "next/router";
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { APITokenService } from "services/api_token.service";
// hooks
import useToast from "hooks/use-toast";
// ui
//ui
import { Button } from "@plane/ui";
// types
import { IApiToken } from "types/api_token";
// fetch-keys
import { API_TOKENS_LIST } from "constants/fetch-keys";
//hooks
import useToast from "hooks/use-toast";
//services
import { ApiTokenService } from "services/api_token.service";
//headless ui
import { Dialog, Transition } from "@headlessui/react";
type Props = {
isOpen: boolean;
onClose: () => void;
tokenId: string;
handleClose: () => void;
tokenId?: string;
};
const apiTokenService = new APITokenService();
export const DeleteApiTokenModal: FC<Props> = (props) => {
const { isOpen, onClose, tokenId } = props;
// states
const apiTokenService = new ApiTokenService();
const DeleteTokenModal: FC<Props> = ({ isOpen, handleClose, tokenId }) => {
const [deleteLoading, setDeleteLoading] = useState<boolean>(false);
// hooks
const { setToastAlert } = useToast();
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const handleClose = () => {
onClose();
setDeleteLoading(false);
};
const { workspaceSlug, tokenId: tokenIdFromQuery } = router.query;
const handleDeletion = () => {
if (!workspaceSlug) return;
if (!workspaceSlug || (!tokenIdFromQuery && !tokenId)) return;
const token = tokenId || tokenIdFromQuery;
setDeleteLoading(true);
apiTokenService
.deleteApiToken(workspaceSlug.toString(), tokenId)
.deleteApiToken(workspaceSlug.toString(), token!.toString())
.then(() => {
setToastAlert({
message: "Token deleted successfully",
type: "success",
title: "Success!",
message: "Token deleted successfully.",
title: "Success",
});
mutate<IApiToken[]>(
API_TOKENS_LIST(workspaceSlug.toString()),
(prevData) => (prevData ?? []).filter((token) => token.id !== tokenId),
false
);
handleClose();
router.replace(`/${workspaceSlug}/settings/api-tokens/`);
})
.catch((err) =>
.catch((err) => {
setToastAlert({
message: err?.message,
type: "error",
title: "Error",
message: err?.message ?? "Something went wrong. Please try again.",
})
)
.finally(() => setDeleteLoading(false));
});
})
.finally(() => {
setDeleteLoading(false);
handleClose();
});
};
return (
@@ -95,24 +80,22 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-3 p-4">
<div className="flex flex-col gap-3 p-6">
<div className="flex w-full items-center justify-start">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">
Are you sure you want to delete the token?
</h3>
<h3 className="text-xl font-semibold 2xl:text-2xl">Are you sure you want to revoke access?</h3>
</div>
<span>
<p className="text-sm text-custom-text-400">
Any application using this token will no longer have the access to Plane data. This action cannot
be undone.
<p className="text-base font-normal text-custom-text-400">
Any applications Using this developer key will no longer have the access to Plane Data. This
Action cannot be undone.
</p>
</span>
<div className="flex justify-end mt-2 gap-2">
<Button variant="neutral-primary" onClick={handleClose} size="sm">
<Button variant="neutral-primary" onClick={handleClose} disabled={deleteLoading}>
Cancel
</Button>
<Button variant="danger" onClick={handleDeletion} loading={deleteLoading} size="sm">
{deleteLoading ? "Deleting..." : "Delete"}
<Button variant="primary" onClick={handleDeletion} loading={deleteLoading} disabled={deleteLoading}>
{deleteLoading ? "Revoking..." : "Revoke"}
</Button>
</div>
</div>
@@ -124,3 +107,5 @@ export const DeleteApiTokenModal: FC<Props> = (props) => {
</Transition.Root>
);
};
export default DeleteTokenModal;
+21 -16
View File
@@ -1,31 +1,36 @@
// react
import React from "react";
// next
import Image from "next/image";
import { useRouter } from "next/router";
// ui
import { Button } from "@plane/ui";
// assets
import emptyApiTokens from "public/empty-state/api-token.svg";
type Props = {
onClick: () => void;
};
export const ApiTokenEmptyState: React.FC<Props> = (props) => {
const { onClick } = props;
const ApiTokenEmptyState = () => {
const router = useRouter();
return (
<div
className={`flex items-center justify-center mx-auto rounded-sm border border-custom-border-200 bg-custom-background-90 py-10 px-16 w-full`}
>
<div className={`flex items-center justify-center mx-auto rounded-sm border border-custom-border-200 bg-custom-background-90 py-10 px-16 w-full`}>
<div className="text-center flex flex-col items-center w-full">
<Image src={emptyApiTokens} className="w-52 sm:w-60" alt="empty" />
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No API tokens</h6>
<p className="text-custom-text-300 mb-7 sm:mb-8">
Create API tokens for safe and easy data sharing with external apps, maintaining control and security.
</p>
<Button className="flex items-center gap-1.5" onClick={onClick}>
Add token
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No API Tokens</h6>
{
<p className="text-custom-text-300 mb-7 sm:mb-8">
Create API tokens for safe and easy data sharing with external apps, maintaining control and security
</p>
}
<Button
className="flex items-center gap-1.5"
onClick={() => {
router.push(`${router.asPath}/create/`);
}}
>
Add Token
</Button>
</div>
</div>
);
};
export default ApiTokenEmptyState;
-4
View File
@@ -1,4 +0,0 @@
export * from "./modal";
export * from "./delete-token-modal";
export * from "./empty-state";
export * from "./token-list-item";
@@ -1,133 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { APITokenService } from "services/api_token.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { CreateApiTokenForm, GeneratedTokenDetails } from "components/api-token";
// helpers
import { csvDownload } from "helpers/download.helper";
import { renderFormattedDate } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
// fetch-keys
import { API_TOKENS_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
onClose: () => void;
};
// services
const apiTokenService = new APITokenService();
export const CreateApiTokenModal: React.FC<Props> = (props) => {
const { isOpen, onClose } = props;
// states
const [neverExpires, setNeverExpires] = useState<boolean>(false);
const [generatedToken, setGeneratedToken] = useState<IApiToken | null | undefined>(null);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// toast alert
const { setToastAlert } = useToast();
const handleClose = () => {
onClose();
setTimeout(() => {
setNeverExpires(false);
setGeneratedToken(null);
}, 350);
};
const downloadSecretKey = (data: IApiToken) => {
const csvData = {
Title: data.label,
Description: data.description,
Expiry: data.expired_at ? renderFormattedDate(data.expired_at) : "Never expires",
"Secret key": data.token ?? "",
};
csvDownload(csvData, `secret-key-${Date.now()}`);
};
const handleCreateToken = async (data: Partial<IApiToken>) => {
if (!workspaceSlug) return;
// make the request to generate the token
await apiTokenService
.createApiToken(workspaceSlug.toString(), data)
.then((res) => {
setGeneratedToken(res);
downloadSecretKey(res);
mutate<IApiToken[]>(
API_TOKENS_LIST(workspaceSlug.toString()),
(prevData) => {
if (!prevData) return;
return [res, ...prevData];
},
false
);
})
.catch((err) => {
setToastAlert({
message: err.message,
type: "error",
title: "Error",
});
throw err;
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={() => {}}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="h-full w-full grid place-items-center text-center p-4">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all px-4 sm:w-full sm:max-w-2xl">
{generatedToken ? (
<GeneratedTokenDetails handleClose={handleClose} tokenDetails={generatedToken} />
) : (
<CreateApiTokenForm
handleClose={handleClose}
neverExpires={neverExpires}
toggleNeverExpires={() => setNeverExpires((prevData) => !prevData)}
onSubmit={handleCreateToken}
/>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
-247
View File
@@ -1,247 +0,0 @@
import { useState } from "react";
import { add } from "date-fns";
import { Controller, useForm } from "react-hook-form";
import { Calendar } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// components
import { CustomDatePicker } from "components/ui";
// ui
import { Button, CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui";
// helpers
import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
type Props = {
handleClose: () => void;
neverExpires: boolean;
toggleNeverExpires: () => void;
onSubmit: (data: Partial<IApiToken>) => Promise<void>;
};
const EXPIRY_DATE_OPTIONS = [
{
key: "1_week",
label: "1 week",
value: { weeks: 1 },
},
{
key: "1_month",
label: "1 month",
value: { months: 1 },
},
{
key: "3_months",
label: "3 months",
value: { months: 3 },
},
{
key: "1_year",
label: "1 year",
value: { years: 1 },
},
];
const defaultValues: Partial<IApiToken> = {
label: "",
description: "",
expired_at: null,
};
const getExpiryDate = (val: string): string | null => {
const today = new Date();
const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value;
if (dateToAdd) {
const expiryDate = add(today, dateToAdd);
return renderFormattedDate(expiryDate);
}
return null;
};
export const CreateApiTokenForm: React.FC<Props> = (props) => {
const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props;
// states
const [customDate, setCustomDate] = useState<Date | null>(null);
// toast alert
const { setToastAlert } = useToast();
// form
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
} = useForm<IApiToken>({ defaultValues });
const handleFormSubmit = async (data: IApiToken) => {
// if never expires is toggled off, and the user has not selected a custom date or a predefined date, show an error
if (!neverExpires && (!data.expired_at || (data.expired_at === "custom" && !customDate)))
return setToastAlert({
type: "error",
title: "Error!",
message: "Please select an expiration date.",
});
const payload: Partial<IApiToken> = {
label: data.label,
description: data.description,
};
// if never expires is toggled on, set expired_at to null
if (neverExpires) payload.expired_at = null;
// if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date
else if (data.expired_at === "custom") payload.expired_at = renderFormattedPayloadDate(customDate ?? new Date());
// if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date
else {
const expiryDate = getExpiryDate(data.expired_at ?? "");
if (expiryDate) payload.expired_at = renderFormattedPayloadDate(expiryDate);
}
await onSubmit(payload).then(() => {
reset(defaultValues);
setCustomDate(null);
});
};
const today = new Date();
const tomorrow = add(today, { days: 1 });
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-4">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Create token</h3>
<div className="space-y-3">
<div>
<Controller
control={control}
name="label"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
validate: (val) => val.trim() !== "" || "Title is required",
}}
render={({ field: { value, onChange } }) => (
<Input
type="text"
value={value}
onChange={onChange}
hasError={Boolean(errors.label)}
placeholder="Token title"
className="text-sm font-medium w-full"
/>
)}
/>
{errors.label && <span className="text-xs text-red-500">{errors.label.message}</span>}
</div>
<Controller
control={control}
name="description"
render={({ field: { value, onChange } }) => (
<TextArea
value={value}
onChange={onChange}
hasError={Boolean(errors.description)}
placeholder="Token description"
className="text-sm h-24 w-full"
/>
)}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Controller
control={control}
name="expired_at"
render={({ field: { onChange, value } }) => {
const selectedOption = EXPIRY_DATE_OPTIONS.find((option) => option.key === value);
return (
<CustomSelect
customButton={
<div
className={`flex items-center gap-2 border-[0.5px] border-custom-border-200 rounded py-1 px-2 ${
neverExpires ? "text-custom-text-400" : ""
}`}
>
<Calendar className="h-3 w-3" />
{value === "custom"
? "Custom date"
: selectedOption
? selectedOption.label
: "Set expiration date"}
</div>
}
value={value}
onChange={onChange}
disabled={neverExpires}
>
{EXPIRY_DATE_OPTIONS.map((option) => (
<CustomSelect.Option key={option.key} value={option.key}>
{option.label}
</CustomSelect.Option>
))}
<CustomSelect.Option value="custom">Custom</CustomSelect.Option>
</CustomSelect>
);
}}
/>
{watch("expired_at") === "custom" && (
<CustomDatePicker
value={customDate}
onChange={(date) => setCustomDate(date ? new Date(date) : null)}
minDate={tomorrow}
customInput={
<div
className={`flex items-center gap-2 py-1 px-2 text-xs cursor-pointer !rounded border-[0.5px] border-custom-border-200 !shadow-none !duration-0 ${
customDate ? "w-[7.5rem]" : ""
} ${neverExpires ? "text-custom-text-400 !cursor-not-allowed" : "hover:bg-custom-background-80"}`}
>
<Calendar className="h-3 w-3" />
{customDate ? renderFormattedDate(customDate) : "Set date"}
</div>
}
disabled={neverExpires}
/>
)}
</div>
{!neverExpires && (
<span className="text-xs text-custom-text-400">
{watch("expired_at") === "custom"
? customDate
? `Expires ${renderFormattedDate(customDate)}`
: null
: watch("expired_at")
? `Expires ${getExpiryDate(watch("expired_at") ?? "")}`
: null}
</span>
)}
</div>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<div className="flex cursor-pointer items-center gap-1.5" onClick={toggleNeverExpires}>
<div className="flex cursor-pointer items-center justify-center">
<ToggleSwitch value={neverExpires} onChange={() => {}} size="sm" />
</div>
<span className="text-xs">Never expires</span>
</div>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Discard
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Generating..." : "Generate token"}
</Button>
</div>
</div>
</form>
);
};
@@ -1,61 +0,0 @@
import { Copy } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IApiToken } from "types/api_token";
type Props = {
handleClose: () => void;
tokenDetails: IApiToken;
};
export const GeneratedTokenDetails: React.FC<Props> = (props) => {
const { handleClose, tokenDetails } = props;
const { setToastAlert } = useToast();
const copyApiToken = (token: string) => {
copyTextToClipboard(token).then(() =>
setToastAlert({
type: "success",
title: "Success!",
message: "Token copied to clipboard.",
})
);
};
return (
<div>
<div className="space-y-3">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">Key created</h3>
<p className="text-sm text-custom-text-400">
Copy and save this secret key in Plane Pages. You can{"'"}t see this key after you hit Close. A CSV file
containing the key has been downloaded.
</p>
</div>
<button
type="button"
onClick={() => copyApiToken(tokenDetails.token ?? "")}
className="mt-4 w-full border-[0.5px] border-custom-border-200 py-2 px-3 flex items-center justify-between font-medium rounded-md text-sm outline-none"
>
{tokenDetails.token}
<Tooltip tooltipContent="Copy secret key">
<Copy className="h-4 w-4 text-custom-text-400" />
</Tooltip>
</button>
<div className="mt-6 flex items-center justify-between">
<p className="text-custom-text-400 text-xs">
{tokenDetails.expired_at ? `Expires ${renderFormattedDate(tokenDetails.expired_at)}` : "Never expires"}
</p>
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Close
</Button>
</div>
</div>
);
};
-3
View File
@@ -1,3 +0,0 @@
export * from "./create-token-modal";
export * from "./form";
export * from "./generated-token-details";
@@ -1,58 +0,0 @@
import { useState } from "react";
import { XCircle } from "lucide-react";
// components
import { DeleteApiTokenModal } from "components/api-token";
// ui
import { Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate, timeAgo } from "helpers/date-time.helper";
// types
import { IApiToken } from "types/api_token";
type Props = {
token: IApiToken;
};
export const ApiTokenListItem: React.FC<Props> = (props) => {
const { token } = props;
// states
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
return (
<>
<DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} />
<div className="group relative border-b border-custom-border-200 flex flex-col justify-center py-3 px-4">
<Tooltip tooltipContent="Delete token">
<button
onClick={() => setDeleteModalOpen(true)}
className="hidden group-hover:grid absolute right-4 place-items-center"
>
<XCircle className="h-4 w-4 text-red-500" />
</button>
</Tooltip>
<div className="flex items-center w-4/5">
<h5 className="text-sm font-medium truncate">{token.label}</h5>
<span
className={`${
token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400"
} flex items-center px-2 h-4 rounded-sm max-h-fit ml-2 text-xs font-medium`}
>
{token.is_active ? "Active" : "Expired"}
</span>
</div>
<div className="flex flex-col justify-center w-full mt-1">
{token.description.trim() !== "" && (
<p className="text-sm mb-1 break-words max-w-[70%]">{token.description}</p>
)}
<p className="text-xs mb-1 leading-6 text-custom-text-400">
{token.is_active
? token.expired_at
? `Expires ${renderFormattedDate(token.expired_at!)}`
: "Never expires"
: `Expired ${timeAgo(token.expired_at)}`}
</p>
</div>
</div>
</>
);
};

Some files were not shown because too many files have changed in this diff Show More